Synced files: - Quote wizard frontend (all components, hooks, types, config) - API updates (config, models, routers, schemas, services) - Client work (bg-builders, gurushow) - Scripts (BGB Lesley termination, CIPP, Datto, migration) - Temp files (Bardach contacts, VWP investigation, misc) - Credentials and session logs - Email service, PHP API, session logs Machine: ACG-M-L5090 Timestamp: 2026-03-10 19:11:00 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1061 lines
35 KiB
PHP
1061 lines
35 KiB
PHP
<?php
|
|
/**
|
|
* Quote business logic service.
|
|
*
|
|
* Handles all database operations for quotes: create, read, update, submit,
|
|
* item management, totals calculation, activity logging, and admin functions.
|
|
*
|
|
* All functions accept a PDO instance and return associative arrays.
|
|
*/
|
|
|
|
// Deny direct access
|
|
if (basename($_SERVER['SCRIPT_FILENAME'] ?? '') === basename(__FILE__)) {
|
|
http_response_code(403);
|
|
exit('Direct access denied.');
|
|
}
|
|
|
|
require_once __DIR__ . '/../config.php';
|
|
require_once __DIR__ . '/../helpers.php';
|
|
require_once __DIR__ . '/../db.php';
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Constants
|
|
// --------------------------------------------------------------------------
|
|
|
|
define('VALID_STATUSES', [
|
|
'draft', 'submitted', 'viewed', 'followed_up', 'converted', 'expired', 'archived'
|
|
]);
|
|
|
|
define('VALID_CATEGORIES', [
|
|
'gps_monitoring', 'support_plan', 'voip', 'web_hosting', 'email',
|
|
'hardware', 'addon', 'backup', 'security', 'other'
|
|
]);
|
|
|
|
define('VALID_BILLING_FREQUENCIES', ['monthly', 'yearly', 'one_time']);
|
|
|
|
define('SUBMITTED_STATUSES', ['submitted', 'viewed', 'followed_up', 'converted']);
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Table name helpers
|
|
// --------------------------------------------------------------------------
|
|
|
|
function tbl_quotes(): string { return DB_TABLE_PREFIX . 'quotes'; }
|
|
function tbl_items(): string { return DB_TABLE_PREFIX . 'quote_items'; }
|
|
function tbl_activity(): string { return DB_TABLE_PREFIX . 'quote_activity'; }
|
|
function tbl_notif(): string { return DB_TABLE_PREFIX . 'quote_notifications'; }
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Activity logging
|
|
// --------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Log an activity record for a quote.
|
|
*
|
|
* @param PDO $db Database connection.
|
|
* @param string $quote_id UUID of the quote.
|
|
* @param string $action Action name (created, updated, submitted, etc.).
|
|
* @param string|null $details Freeform details (stored as JSON wrapper for CHECK compat).
|
|
* @param string|null $step_name Wizard step name.
|
|
* @param string|null $ip Client IP address.
|
|
*/
|
|
function log_activity(
|
|
PDO $db,
|
|
string $quote_id,
|
|
string $action,
|
|
?string $details = null,
|
|
?string $step_name = null,
|
|
?string $ip = null
|
|
): void {
|
|
$id = generate_uuid();
|
|
$details_json = $details !== null ? json_encode(['message' => $details]) : null;
|
|
|
|
$sql = sprintf(
|
|
'INSERT INTO %s (id, quote_id, action, step_name, details, ip_address, created_at)
|
|
VALUES (:id, :quote_id, :action, :step_name, :details, :ip, :created_at)',
|
|
tbl_activity()
|
|
);
|
|
|
|
$stmt = $db->prepare($sql);
|
|
$stmt->execute([
|
|
':id' => $id,
|
|
':quote_id' => $quote_id,
|
|
':action' => $action,
|
|
':step_name' => $step_name,
|
|
':details' => $details_json,
|
|
':ip' => $ip,
|
|
':created_at' => utc_now(),
|
|
]);
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Totals calculation
|
|
// --------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Calculate monthly and setup totals from a list of item rows.
|
|
*
|
|
* Monthly calculation:
|
|
* - monthly items: unit_price * quantity
|
|
* - yearly items: unit_price * quantity / 12
|
|
* - one_time items: 0
|
|
*
|
|
* Setup total: sum of all setup_price values.
|
|
*
|
|
* @param array $items Array of item associative arrays.
|
|
* @return array{monthly: string, setup: string} Formatted totals with 2 decimal places.
|
|
*/
|
|
function calculate_totals(array $items): array
|
|
{
|
|
$monthly = '0';
|
|
$setup = '0';
|
|
|
|
foreach ($items as $item) {
|
|
$unit_price = $item['unit_price'] ?? '0';
|
|
$qty = (int)($item['quantity'] ?? 1);
|
|
$setup_price = $item['setup_price'] ?? '0';
|
|
$freq = $item['billing_frequency'] ?? 'monthly';
|
|
|
|
$line_total = bcmul($unit_price, (string)$qty, 4);
|
|
|
|
if ($freq === 'monthly') {
|
|
$monthly = bcadd($monthly, $line_total, 4);
|
|
} elseif ($freq === 'yearly') {
|
|
$monthly_portion = bcdiv($line_total, '12', 4);
|
|
$monthly = bcadd($monthly, $monthly_portion, 4);
|
|
}
|
|
// one_time items contribute 0 to monthly
|
|
|
|
$setup = bcadd($setup, $setup_price, 4);
|
|
}
|
|
|
|
return [
|
|
'monthly' => number_format((float)$monthly, 2, '.', ''),
|
|
'setup' => number_format((float)$setup, 2, '.', ''),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Recalculate and persist totals for a quote by summing its items.
|
|
*
|
|
* @param PDO $db Database connection.
|
|
* @param string $quote_id UUID of the quote.
|
|
*/
|
|
function recalculate_totals(PDO $db, string $quote_id): void
|
|
{
|
|
$items = fetch_items_for_quote($db, $quote_id);
|
|
$totals = calculate_totals($items);
|
|
|
|
$sql = sprintf(
|
|
'UPDATE %s SET monthly_total = :monthly, setup_total = :setup, updated_at = :now WHERE id = :id',
|
|
tbl_quotes()
|
|
);
|
|
$stmt = $db->prepare($sql);
|
|
$stmt->execute([
|
|
':monthly' => $totals['monthly'],
|
|
':setup' => $totals['setup'],
|
|
':now' => utc_now(),
|
|
':id' => $quote_id,
|
|
]);
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Item helpers
|
|
// --------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Fetch all items for a given quote.
|
|
*
|
|
* @param PDO $db Database connection.
|
|
* @param string $quote_id UUID of the quote.
|
|
* @return array List of item rows.
|
|
*/
|
|
function fetch_items_for_quote(PDO $db, string $quote_id): array
|
|
{
|
|
$sql = sprintf('SELECT * FROM %s WHERE quote_id = :qid ORDER BY created_at ASC', tbl_items());
|
|
$stmt = $db->prepare($sql);
|
|
$stmt->execute([':qid' => $quote_id]);
|
|
return $stmt->fetchAll();
|
|
}
|
|
|
|
/**
|
|
* Insert a single item row.
|
|
*
|
|
* @param PDO $db Database connection.
|
|
* @param string $quote_id UUID of the parent quote.
|
|
* @param array $item Item data with keys matching the schema.
|
|
* @return string The generated item UUID.
|
|
*/
|
|
function insert_item(PDO $db, string $quote_id, array $item): string
|
|
{
|
|
$id = generate_uuid();
|
|
|
|
$sql = sprintf(
|
|
'INSERT INTO %s
|
|
(id, quote_id, category, product_code, product_name, description,
|
|
quantity, unit_price, setup_price, billing_frequency, tier, is_recommended, created_at)
|
|
VALUES
|
|
(:id, :qid, :cat, :code, :name, :desc, :qty, :price, :setup, :freq, :tier, :rec, :created_at)',
|
|
tbl_items()
|
|
);
|
|
|
|
$stmt = $db->prepare($sql);
|
|
$stmt->execute([
|
|
':id' => $id,
|
|
':qid' => $quote_id,
|
|
':cat' => $item['category'] ?? 'other',
|
|
':code' => $item['product_code'] ?? '',
|
|
':name' => $item['product_name'] ?? '',
|
|
':desc' => $item['description'] ?? null,
|
|
':qty' => (int)($item['quantity'] ?? 1),
|
|
':price' => $item['unit_price'] ?? '0.00',
|
|
':setup' => $item['setup_price'] ?? '0.00',
|
|
':freq' => $item['billing_frequency'] ?? 'monthly',
|
|
':tier' => $item['tier'] ?? null,
|
|
':rec' => (int)($item['is_recommended'] ?? 0),
|
|
':created_at' => utc_now(),
|
|
]);
|
|
|
|
return $id;
|
|
}
|
|
|
|
/**
|
|
* Delete all items for a quote.
|
|
*
|
|
* @param PDO $db Database connection.
|
|
* @param string $quote_id UUID of the quote.
|
|
*/
|
|
function delete_all_items(PDO $db, string $quote_id): void
|
|
{
|
|
$sql = sprintf('DELETE FROM %s WHERE quote_id = :qid', tbl_items());
|
|
$stmt = $db->prepare($sql);
|
|
$stmt->execute([':qid' => $quote_id]);
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Quote CRUD
|
|
// --------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Create a new quote draft.
|
|
*
|
|
* Generates a UUID and access token, inserts the quote row, optionally
|
|
* inserts initial items, calculates totals, and logs a "created" activity.
|
|
*
|
|
* @param PDO $db Database connection.
|
|
* @param array $data Request body data (employee_count, items).
|
|
* @param string|null $ip Client IP.
|
|
* @param string|null $ua User-Agent.
|
|
* @return array The created quote row (without items).
|
|
*/
|
|
function create_quote(PDO $db, array $data, ?string $ip = null, ?string $ua = null): array
|
|
{
|
|
$id = generate_uuid();
|
|
$token = generate_access_token();
|
|
$now = utc_now();
|
|
$expires = gmdate('Y-m-d H:i:s', strtotime("+".QUOTE_DRAFT_EXPIRY_DAYS." days"));
|
|
|
|
$employee_count = isset($data['employee_count']) ? (int)$data['employee_count'] : null;
|
|
|
|
$sql = sprintf(
|
|
'INSERT INTO %s
|
|
(id, access_token, status, employee_count, ip_address, user_agent,
|
|
monthly_total, setup_total, expires_at, created_at, updated_at)
|
|
VALUES
|
|
(:id, :token, :status, :emp, :ip, :ua, :mt, :st, :exp, :now, :now2)',
|
|
tbl_quotes()
|
|
);
|
|
|
|
try {
|
|
$db->beginTransaction();
|
|
|
|
$stmt = $db->prepare($sql);
|
|
$stmt->execute([
|
|
':id' => $id,
|
|
':token' => $token,
|
|
':status' => 'draft',
|
|
':emp' => $employee_count,
|
|
':ip' => $ip,
|
|
':ua' => $ua,
|
|
':mt' => '0.00',
|
|
':st' => '0.00',
|
|
':exp' => $expires,
|
|
':now' => $now,
|
|
':now2' => $now,
|
|
]);
|
|
|
|
// Insert initial items if provided
|
|
if (!empty($data['items']) && is_array($data['items'])) {
|
|
foreach ($data['items'] as $item) {
|
|
insert_item($db, $id, $item);
|
|
}
|
|
recalculate_totals($db, $id);
|
|
}
|
|
|
|
// Log activity
|
|
log_activity(
|
|
$db, $id, 'created',
|
|
"Quote draft created, employee_count={$employee_count}",
|
|
null, $ip
|
|
);
|
|
|
|
$db->commit();
|
|
|
|
} catch (PDOException $e) {
|
|
$db->rollBack();
|
|
// If token collision, retry once
|
|
if (strpos($e->getMessage(), 'access_token') !== false) {
|
|
app_log('WARNING', 'Access token collision, retrying create_quote');
|
|
return create_quote($db, $data, $ip, $ua);
|
|
}
|
|
app_log('ERROR', 'Failed to create quote: ' . $e->getMessage());
|
|
error_response('Failed to create quote: ' . $e->getMessage(), 500);
|
|
}
|
|
|
|
return fetch_quote_row($db, $id);
|
|
}
|
|
|
|
/**
|
|
* Fetch a raw quote row by ID.
|
|
*
|
|
* @param PDO $db Database connection.
|
|
* @param string $id Quote UUID.
|
|
* @return array|null Quote row or null.
|
|
*/
|
|
function fetch_quote_row(PDO $db, string $id): ?array
|
|
{
|
|
$sql = sprintf('SELECT * FROM %s WHERE id = :id', tbl_quotes());
|
|
$stmt = $db->prepare($sql);
|
|
$stmt->execute([':id' => $id]);
|
|
$row = $stmt->fetch();
|
|
return $row ?: null;
|
|
}
|
|
|
|
/**
|
|
* Fetch a quote by access token, with expiration check.
|
|
*
|
|
* If the quote is a draft and expired, its status is updated to "expired".
|
|
*
|
|
* @param PDO $db Database connection.
|
|
* @param string $token Access token.
|
|
* @return array Quote row.
|
|
*/
|
|
function get_quote_by_token(PDO $db, string $token): array
|
|
{
|
|
$sql = sprintf('SELECT * FROM %s WHERE access_token = :token', tbl_quotes());
|
|
$stmt = $db->prepare($sql);
|
|
$stmt->execute([':token' => $token]);
|
|
$quote = $stmt->fetch();
|
|
|
|
if (!$quote) {
|
|
error_response('Quote not found', 404);
|
|
}
|
|
|
|
// Check expiration for draft quotes
|
|
if (
|
|
$quote['status'] === 'draft'
|
|
&& $quote['expires_at'] !== null
|
|
&& strtotime($quote['expires_at']) < time()
|
|
) {
|
|
$upd = sprintf('UPDATE %s SET status = :s, updated_at = :now WHERE id = :id', tbl_quotes());
|
|
$stmt2 = $db->prepare($upd);
|
|
$stmt2->execute([':s' => 'expired', ':now' => utc_now(), ':id' => $quote['id']]);
|
|
$quote['status'] = 'expired';
|
|
}
|
|
|
|
return $quote;
|
|
}
|
|
|
|
/**
|
|
* Fetch a quote by its UUID (admin access).
|
|
*
|
|
* @param PDO $db Database connection.
|
|
* @param string $id Quote UUID.
|
|
* @return array Quote row.
|
|
*/
|
|
function get_quote_by_id(PDO $db, string $id): array
|
|
{
|
|
$quote = fetch_quote_row($db, $id);
|
|
if (!$quote) {
|
|
error_response("Quote with ID {$id} not found", 404);
|
|
}
|
|
return $quote;
|
|
}
|
|
|
|
/**
|
|
* Build the full public quote response (quote + items, with computed fields).
|
|
*
|
|
* @param PDO $db Database connection.
|
|
* @param array $quote Raw quote row.
|
|
* @return array Response-ready associative array.
|
|
*/
|
|
function build_quote_response(PDO $db, array $quote): array
|
|
{
|
|
$items_raw = fetch_items_for_quote($db, $quote['id']);
|
|
$items = array_map('format_item_response', $items_raw);
|
|
|
|
return [
|
|
'id' => $quote['id'],
|
|
'access_token' => $quote['access_token'],
|
|
'status' => $quote['status'],
|
|
'company_name' => $quote['company_name'],
|
|
'contact_name' => $quote['contact_name'],
|
|
'contact_email' => $quote['contact_email'],
|
|
'contact_phone' => $quote['contact_phone'],
|
|
'employee_count' => $quote['employee_count'] !== null ? (int)$quote['employee_count'] : null,
|
|
'monthly_total' => number_format((float)$quote['monthly_total'], 2, '.', ''),
|
|
'setup_total' => number_format((float)$quote['setup_total'], 2, '.', ''),
|
|
'expires_at' => format_datetime($quote['expires_at']),
|
|
'submitted_at' => format_datetime($quote['submitted_at']),
|
|
'created_at' => format_datetime($quote['created_at']),
|
|
'updated_at' => format_datetime($quote['updated_at']),
|
|
'items' => $items,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Build the full admin quote response (quote + items + activities + notifications).
|
|
*
|
|
* @param PDO $db Database connection.
|
|
* @param array $quote Raw quote row.
|
|
* @return array Response-ready associative array.
|
|
*/
|
|
function build_admin_quote_response(PDO $db, array $quote): array
|
|
{
|
|
$base = build_quote_response($db, $quote);
|
|
|
|
// Add admin-only fields
|
|
$base['ip_address'] = $quote['ip_address'];
|
|
$base['user_agent'] = $quote['user_agent'];
|
|
|
|
// Fetch activities
|
|
$sql_act = sprintf(
|
|
'SELECT * FROM %s WHERE quote_id = :qid ORDER BY created_at DESC',
|
|
tbl_activity()
|
|
);
|
|
$stmt = $db->prepare($sql_act);
|
|
$stmt->execute([':qid' => $quote['id']]);
|
|
$activities_raw = $stmt->fetchAll();
|
|
|
|
$base['activities'] = array_map(function ($a) {
|
|
return [
|
|
'id' => $a['id'],
|
|
'quote_id' => $a['quote_id'],
|
|
'action' => $a['action'],
|
|
'step_name' => $a['step_name'],
|
|
'details' => $a['details'],
|
|
'ip_address' => $a['ip_address'],
|
|
'created_at' => format_datetime($a['created_at']),
|
|
];
|
|
}, $activities_raw);
|
|
|
|
// Fetch notifications
|
|
$sql_notif = sprintf(
|
|
'SELECT * FROM %s WHERE quote_id = :qid ORDER BY created_at DESC',
|
|
tbl_notif()
|
|
);
|
|
$stmt2 = $db->prepare($sql_notif);
|
|
$stmt2->execute([':qid' => $quote['id']]);
|
|
$notifs_raw = $stmt2->fetchAll();
|
|
|
|
$base['notifications'] = array_map(function ($n) {
|
|
return [
|
|
'id' => $n['id'],
|
|
'quote_id' => $n['quote_id'],
|
|
'notification_type' => $n['notification_type'],
|
|
'recipient' => $n['recipient'],
|
|
'subject' => $n['subject'],
|
|
'status' => $n['status'],
|
|
'attempts' => (int)$n['attempts'],
|
|
'last_attempt_at' => format_datetime($n['last_attempt_at']),
|
|
'sent_at' => format_datetime($n['sent_at']),
|
|
'error_message' => $n['error_message'],
|
|
'created_at' => format_datetime($n['created_at']),
|
|
];
|
|
}, $notifs_raw);
|
|
|
|
return $base;
|
|
}
|
|
|
|
/**
|
|
* Format a raw item row into the API response shape with computed fields.
|
|
*
|
|
* @param array $item Raw item row from database.
|
|
* @return array Formatted item response.
|
|
*/
|
|
function format_item_response(array $item): array
|
|
{
|
|
$unit_price = $item['unit_price'] ?? '0.00';
|
|
$qty = (int)($item['quantity'] ?? 1);
|
|
$freq = $item['billing_frequency'] ?? 'monthly';
|
|
$setup = $item['setup_price'] ?? '0.00';
|
|
|
|
$line_total = bcmul($unit_price, (string)$qty, 2);
|
|
|
|
if ($freq === 'monthly') {
|
|
$monthly_amount = $line_total;
|
|
} elseif ($freq === 'yearly') {
|
|
$monthly_amount = bcdiv($line_total, '12', 2);
|
|
} else {
|
|
$monthly_amount = '0.00';
|
|
}
|
|
|
|
return [
|
|
'id' => $item['id'],
|
|
'quote_id' => $item['quote_id'],
|
|
'category' => $item['category'],
|
|
'product_code' => $item['product_code'],
|
|
'product_name' => $item['product_name'],
|
|
'description' => $item['description'],
|
|
'quantity' => $qty,
|
|
'unit_price' => number_format((float)$unit_price, 2, '.', ''),
|
|
'setup_price' => number_format((float)$setup, 2, '.', ''),
|
|
'billing_frequency' => $freq,
|
|
'tier' => $item['tier'],
|
|
'is_recommended' => (bool)$item['is_recommended'],
|
|
'line_total' => number_format((float)$line_total, 2, '.', ''),
|
|
'monthly_amount' => number_format((float)$monthly_amount, 2, '.', ''),
|
|
'created_at' => format_datetime($item['created_at']),
|
|
];
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Update quote
|
|
// --------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Update a draft quote's fields and optionally replace all items.
|
|
*
|
|
* Only quotes with status "draft" can be updated. If items are provided
|
|
* in the data, all existing items are deleted and replaced.
|
|
*
|
|
* @param PDO $db Database connection.
|
|
* @param string $token Access token.
|
|
* @param array $data Update data.
|
|
* @param string|null $ip Client IP.
|
|
* @return array Updated quote row.
|
|
*/
|
|
function update_quote(PDO $db, string $token, array $data, ?string $ip = null): array
|
|
{
|
|
$quote = get_quote_by_token($db, $token);
|
|
|
|
if ($quote['status'] !== 'draft') {
|
|
error_response(
|
|
"Cannot update quote with status '{$quote['status']}'. Only drafts can be modified.",
|
|
400
|
|
);
|
|
}
|
|
|
|
try {
|
|
$db->beginTransaction();
|
|
|
|
$changes = [];
|
|
$updatable_fields = ['company_name', 'contact_name', 'contact_email', 'contact_phone', 'employee_count'];
|
|
$sets = [];
|
|
$params = [':id' => $quote['id']];
|
|
|
|
foreach ($updatable_fields as $field) {
|
|
if (array_key_exists($field, $data)) {
|
|
$old = $quote[$field];
|
|
$new = $data[$field];
|
|
if ($old !== $new) {
|
|
$sets[] = "{$field} = :{$field}";
|
|
$params[":{$field}"] = $new;
|
|
$changes[] = "{$field}: {$old} -> {$new}";
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!empty($sets)) {
|
|
$sets[] = 'updated_at = :now';
|
|
$params[':now'] = utc_now();
|
|
$sql = sprintf('UPDATE %s SET %s WHERE id = :id', tbl_quotes(), implode(', ', $sets));
|
|
$stmt = $db->prepare($sql);
|
|
$stmt->execute($params);
|
|
}
|
|
|
|
// Replace items if provided
|
|
if (array_key_exists('items', $data) && $data['items'] !== null) {
|
|
delete_all_items($db, $quote['id']);
|
|
$item_count = 0;
|
|
if (is_array($data['items'])) {
|
|
foreach ($data['items'] as $item) {
|
|
insert_item($db, $quote['id'], $item);
|
|
$item_count++;
|
|
}
|
|
}
|
|
$changes[] = "items: replaced with {$item_count} items";
|
|
}
|
|
|
|
// Recalculate totals
|
|
recalculate_totals($db, $quote['id']);
|
|
|
|
// Log activity
|
|
if (!empty($changes)) {
|
|
log_activity(
|
|
$db, $quote['id'], 'updated',
|
|
'Quote updated: ' . implode(', ', $changes),
|
|
null, $ip
|
|
);
|
|
}
|
|
|
|
$db->commit();
|
|
|
|
} catch (PDOException $e) {
|
|
$db->rollBack();
|
|
app_log('ERROR', 'Failed to update quote: ' . $e->getMessage());
|
|
error_response('Failed to update quote: ' . $e->getMessage(), 500);
|
|
}
|
|
|
|
return get_quote_by_token($db, $token);
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Add / remove items
|
|
// --------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Add a single item to a draft quote.
|
|
*
|
|
* @param PDO $db Database connection.
|
|
* @param string $token Access token.
|
|
* @param array $item Item data.
|
|
* @param string|null $ip Client IP.
|
|
* @return array Updated quote row.
|
|
*/
|
|
function add_item(PDO $db, string $token, array $item, ?string $ip = null): array
|
|
{
|
|
$quote = get_quote_by_token($db, $token);
|
|
|
|
if ($quote['status'] !== 'draft') {
|
|
error_response('Cannot add items to a non-draft quote', 400);
|
|
}
|
|
|
|
try {
|
|
$db->beginTransaction();
|
|
|
|
insert_item($db, $quote['id'], $item);
|
|
recalculate_totals($db, $quote['id']);
|
|
|
|
log_activity(
|
|
$db, $quote['id'], 'item_added',
|
|
'Added item: ' . ($item['product_name'] ?? 'unknown'),
|
|
null, $ip
|
|
);
|
|
|
|
$db->commit();
|
|
|
|
} catch (PDOException $e) {
|
|
$db->rollBack();
|
|
app_log('ERROR', 'Failed to add item: ' . $e->getMessage());
|
|
error_response('Failed to add item: ' . $e->getMessage(), 500);
|
|
}
|
|
|
|
return get_quote_by_token($db, $token);
|
|
}
|
|
|
|
/**
|
|
* Remove a single item from a draft quote.
|
|
*
|
|
* @param PDO $db Database connection.
|
|
* @param string $token Access token.
|
|
* @param string $item_id UUID of the item to remove.
|
|
* @param string|null $ip Client IP.
|
|
* @return array Updated quote row.
|
|
*/
|
|
function remove_item(PDO $db, string $token, string $item_id, ?string $ip = null): array
|
|
{
|
|
$quote = get_quote_by_token($db, $token);
|
|
|
|
if ($quote['status'] !== 'draft') {
|
|
error_response('Cannot remove items from a non-draft quote', 400);
|
|
}
|
|
|
|
// Find the item
|
|
$sql = sprintf(
|
|
'SELECT * FROM %s WHERE id = :iid AND quote_id = :qid',
|
|
tbl_items()
|
|
);
|
|
$stmt = $db->prepare($sql);
|
|
$stmt->execute([':iid' => $item_id, ':qid' => $quote['id']]);
|
|
$item = $stmt->fetch();
|
|
|
|
if (!$item) {
|
|
error_response("Item with ID {$item_id} not found in this quote", 404);
|
|
}
|
|
|
|
try {
|
|
$db->beginTransaction();
|
|
|
|
$del = sprintf('DELETE FROM %s WHERE id = :iid', tbl_items());
|
|
$stmt2 = $db->prepare($del);
|
|
$stmt2->execute([':iid' => $item_id]);
|
|
|
|
recalculate_totals($db, $quote['id']);
|
|
|
|
log_activity(
|
|
$db, $quote['id'], 'item_removed',
|
|
'Removed item: ' . $item['product_name'],
|
|
null, $ip
|
|
);
|
|
|
|
$db->commit();
|
|
|
|
} catch (PDOException $e) {
|
|
$db->rollBack();
|
|
app_log('ERROR', 'Failed to remove item: ' . $e->getMessage());
|
|
error_response('Failed to remove item: ' . $e->getMessage(), 500);
|
|
}
|
|
|
|
return get_quote_by_token($db, $token);
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Submit quote
|
|
// --------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Submit a draft quote with contact information.
|
|
*
|
|
* Validates the quote is a draft and has items, updates contact fields,
|
|
* sets status to "submitted", extends expiration to 90 days, and creates
|
|
* a notification record.
|
|
*
|
|
* @param PDO $db Database connection.
|
|
* @param string $token Access token.
|
|
* @param array $data Submission data (company_name, contact_name, contact_email, etc.).
|
|
* @param string|null $ip Client IP.
|
|
* @return array The submitted quote row.
|
|
*/
|
|
function submit_quote(PDO $db, string $token, array $data, ?string $ip = null): array
|
|
{
|
|
$quote = get_quote_by_token($db, $token);
|
|
|
|
if ($quote['status'] !== 'draft') {
|
|
error_response(
|
|
"Cannot submit quote with status '{$quote['status']}'. Only drafts can be submitted.",
|
|
400
|
|
);
|
|
}
|
|
|
|
// Must have at least one item
|
|
$items = fetch_items_for_quote($db, $quote['id']);
|
|
if (empty($items)) {
|
|
error_response(
|
|
'Cannot submit quote without any items. Please add at least one service.',
|
|
400
|
|
);
|
|
}
|
|
|
|
$now = utc_now();
|
|
$expires = gmdate('Y-m-d H:i:s', strtotime("+" . QUOTE_SUBMITTED_EXPIRY_DAYS . " days"));
|
|
|
|
try {
|
|
$db->beginTransaction();
|
|
|
|
// Update contact info and status
|
|
$sql = sprintf(
|
|
'UPDATE %s SET
|
|
company_name = :company,
|
|
contact_name = :name,
|
|
contact_email = :email,
|
|
contact_phone = :phone,
|
|
status = :status,
|
|
submitted_at = :submitted,
|
|
expires_at = :expires,
|
|
updated_at = :now
|
|
WHERE id = :id',
|
|
tbl_quotes()
|
|
);
|
|
|
|
$stmt = $db->prepare($sql);
|
|
$stmt->execute([
|
|
':company' => trim($data['company_name'] ?? ''),
|
|
':name' => trim($data['contact_name'] ?? ''),
|
|
':email' => $data['contact_email'] ?? '',
|
|
':phone' => $data['contact_phone'] ?? null,
|
|
':status' => 'submitted',
|
|
':submitted' => $now,
|
|
':expires' => $expires,
|
|
':now' => $now,
|
|
':id' => $quote['id'],
|
|
]);
|
|
|
|
// Refresh the quote row to get updated totals
|
|
$refreshed = fetch_quote_row($db, $quote['id']);
|
|
|
|
// Log activity
|
|
$contact_name = trim($data['contact_name'] ?? '');
|
|
$contact_email = $data['contact_email'] ?? '';
|
|
$company_name = trim($data['company_name'] ?? '');
|
|
|
|
log_activity(
|
|
$db, $quote['id'], 'submitted',
|
|
"Quote submitted by {$contact_name} ({$contact_email}), company={$company_name}, monthly=\${$refreshed['monthly_total']}, setup=\${$refreshed['setup_total']}",
|
|
null, $ip
|
|
);
|
|
|
|
// Create notification record
|
|
$notif_id = generate_uuid();
|
|
$sql_notif = sprintf(
|
|
'INSERT INTO %s
|
|
(id, quote_id, notification_type, recipient, subject, body, status, created_at)
|
|
VALUES
|
|
(:id, :qid, :type, :recipient, :subject, :body, :status, :created_at)',
|
|
tbl_notif()
|
|
);
|
|
$stmt_n = $db->prepare($sql_notif);
|
|
$stmt_n->execute([
|
|
':id' => $notif_id,
|
|
':qid' => $quote['id'],
|
|
':type' => 'email',
|
|
':recipient' => ADMIN_NOTIFICATION_EMAIL,
|
|
':subject' => "New Quote Submission: {$company_name}",
|
|
':body' => "Quote submitted by {$contact_name}. Monthly: \${$refreshed['monthly_total']}",
|
|
':status' => 'pending',
|
|
':created_at' => $now,
|
|
]);
|
|
|
|
$db->commit();
|
|
|
|
} catch (PDOException $e) {
|
|
$db->rollBack();
|
|
app_log('ERROR', 'Failed to submit quote: ' . $e->getMessage());
|
|
error_response('Failed to submit quote: ' . $e->getMessage(), 500);
|
|
}
|
|
|
|
return get_quote_by_token($db, $token);
|
|
}
|
|
|
|
/**
|
|
* Update the most recent notification status for a quote.
|
|
*
|
|
* @param PDO $db Database connection.
|
|
* @param string $quote_id Quote UUID.
|
|
* @param string $status New status (sent, failed).
|
|
* @param string|null $error Error message if failed.
|
|
*/
|
|
function update_notification_status(PDO $db, string $quote_id, string $status, ?string $error = null): void
|
|
{
|
|
$now = utc_now();
|
|
$sql = sprintf(
|
|
'UPDATE %s SET
|
|
status = :status,
|
|
attempts = attempts + 1,
|
|
last_attempt_at = :now,
|
|
sent_at = :sent,
|
|
error_message = :err
|
|
WHERE quote_id = :qid
|
|
ORDER BY created_at DESC
|
|
LIMIT 1',
|
|
tbl_notif()
|
|
);
|
|
|
|
$stmt = $db->prepare($sql);
|
|
$stmt->execute([
|
|
':status' => $status,
|
|
':now' => $now,
|
|
':sent' => $status === 'sent' ? $now : null,
|
|
':err' => $error,
|
|
':qid' => $quote_id,
|
|
]);
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Admin functions
|
|
// --------------------------------------------------------------------------
|
|
|
|
/**
|
|
* List quotes with pagination and optional filters.
|
|
*
|
|
* @param PDO $db Database connection.
|
|
* @param int $skip Offset.
|
|
* @param int $limit Max results.
|
|
* @param string|null $status Filter by status.
|
|
* @param string|null $search Search in company_name, contact_name, contact_email.
|
|
* @return array{quotes: array, total: int}
|
|
*/
|
|
function list_quotes(PDO $db, int $skip = 0, int $limit = 100, ?string $status = null, ?string $search = null): array
|
|
{
|
|
$where = [];
|
|
$params = [];
|
|
|
|
if ($status !== null && $status !== '') {
|
|
$where[] = 'q.status = :status';
|
|
$params[':status'] = $status;
|
|
}
|
|
|
|
if ($search !== null && $search !== '') {
|
|
$where[] = '(q.company_name LIKE :search OR q.contact_name LIKE :search2 OR q.contact_email LIKE :search3)';
|
|
$term = '%' . $search . '%';
|
|
$params[':search'] = $term;
|
|
$params[':search2'] = $term;
|
|
$params[':search3'] = $term;
|
|
}
|
|
|
|
$where_sql = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : '';
|
|
|
|
// Get total count
|
|
$count_sql = sprintf('SELECT COUNT(*) FROM %s q %s', tbl_quotes(), $where_sql);
|
|
$stmt = $db->prepare($count_sql);
|
|
$stmt->execute($params);
|
|
$total = (int)$stmt->fetchColumn();
|
|
|
|
// Get paginated results with item count
|
|
$list_sql = sprintf(
|
|
'SELECT q.*, (SELECT COUNT(*) FROM %s qi WHERE qi.quote_id = q.id) AS item_count
|
|
FROM %s q
|
|
%s
|
|
ORDER BY q.created_at DESC
|
|
LIMIT :lim OFFSET :off',
|
|
tbl_items(), tbl_quotes(), $where_sql
|
|
);
|
|
|
|
$stmt2 = $db->prepare($list_sql);
|
|
foreach ($params as $k => $v) {
|
|
$stmt2->bindValue($k, $v);
|
|
}
|
|
$stmt2->bindValue(':lim', $limit, PDO::PARAM_INT);
|
|
$stmt2->bindValue(':off', $skip, PDO::PARAM_INT);
|
|
$stmt2->execute();
|
|
$rows = $stmt2->fetchAll();
|
|
|
|
$quotes = array_map(function ($row) {
|
|
return [
|
|
'id' => $row['id'],
|
|
'access_token' => $row['access_token'],
|
|
'status' => $row['status'],
|
|
'company_name' => $row['company_name'],
|
|
'contact_name' => $row['contact_name'],
|
|
'contact_email' => $row['contact_email'],
|
|
'employee_count' => $row['employee_count'] !== null ? (int)$row['employee_count'] : null,
|
|
'monthly_total' => number_format((float)$row['monthly_total'], 2, '.', ''),
|
|
'setup_total' => number_format((float)$row['setup_total'], 2, '.', ''),
|
|
'item_count' => (int)$row['item_count'],
|
|
'submitted_at' => format_datetime($row['submitted_at']),
|
|
'created_at' => format_datetime($row['created_at']),
|
|
];
|
|
}, $rows);
|
|
|
|
return ['quotes' => $quotes, 'total' => $total];
|
|
}
|
|
|
|
/**
|
|
* Get dashboard statistics for quotes.
|
|
*
|
|
* @param PDO $db Database connection.
|
|
* @return array Statistics matching QuoteStatsResponse schema.
|
|
*/
|
|
function get_stats(PDO $db): array
|
|
{
|
|
$tbl = tbl_quotes();
|
|
|
|
// Total quotes
|
|
$stmt = $db->query("SELECT COUNT(*) FROM {$tbl}");
|
|
$total_quotes = (int)$stmt->fetchColumn();
|
|
|
|
// Quotes by status
|
|
$stmt2 = $db->query("SELECT status, COUNT(*) as cnt FROM {$tbl} GROUP BY status");
|
|
$status_rows = $stmt2->fetchAll();
|
|
$quotes_by_status = [];
|
|
foreach ($status_rows as $r) {
|
|
$quotes_by_status[$r['status']] = (int)$r['cnt'];
|
|
}
|
|
|
|
// Total values for submitted quotes
|
|
$placeholders = implode(',', array_fill(0, count(SUBMITTED_STATUSES), '?'));
|
|
$val_sql = "SELECT COALESCE(SUM(monthly_total), 0), COALESCE(SUM(setup_total), 0) FROM {$tbl} WHERE status IN ({$placeholders})";
|
|
$stmt3 = $db->prepare($val_sql);
|
|
$stmt3->execute(SUBMITTED_STATUSES);
|
|
$vals = $stmt3->fetch(PDO::FETCH_NUM);
|
|
$total_monthly_value = (float)$vals[0];
|
|
$total_setup_value = (float)$vals[1];
|
|
|
|
// Quotes this month
|
|
$month_start = gmdate('Y-m-01 00:00:00');
|
|
$stmt4 = $db->prepare("SELECT COUNT(*) FROM {$tbl} WHERE created_at >= ?");
|
|
$stmt4->execute([$month_start]);
|
|
$quotes_this_month = (int)$stmt4->fetchColumn();
|
|
|
|
// Submitted this month
|
|
$stmt5 = $db->prepare("SELECT COUNT(*) FROM {$tbl} WHERE submitted_at >= ? AND submitted_at IS NOT NULL");
|
|
$stmt5->execute([$month_start]);
|
|
$quotes_submitted_this_month = (int)$stmt5->fetchColumn();
|
|
|
|
// Calculations
|
|
$submitted_count = 0;
|
|
foreach (SUBMITTED_STATUSES as $s) {
|
|
$submitted_count += ($quotes_by_status[$s] ?? 0);
|
|
}
|
|
|
|
$average_monthly_value = $submitted_count > 0
|
|
? round($total_monthly_value / $submitted_count, 2)
|
|
: 0.00;
|
|
|
|
$draft_count = $quotes_by_status['draft'] ?? 0;
|
|
$total_started = $draft_count + $submitted_count;
|
|
$conversion_rate = $total_started > 0
|
|
? round(($submitted_count / $total_started) * 100, 2)
|
|
: 0.00;
|
|
|
|
return [
|
|
'total_quotes' => $total_quotes,
|
|
'quotes_by_status' => $quotes_by_status,
|
|
'total_monthly_value' => number_format($total_monthly_value, 2, '.', ''),
|
|
'total_setup_value' => number_format($total_setup_value, 2, '.', ''),
|
|
'quotes_this_month' => $quotes_this_month,
|
|
'quotes_submitted_this_month' => $quotes_submitted_this_month,
|
|
'average_monthly_value' => number_format($average_monthly_value, 2, '.', ''),
|
|
'conversion_rate' => number_format($conversion_rate, 2, '.', ''),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Admin update of a quote's status and/or expiration.
|
|
*
|
|
* @param PDO $db Database connection.
|
|
* @param string $quote_id Quote UUID.
|
|
* @param array $data Update data (status, expires_at).
|
|
* @param string $admin_user Admin username for audit trail.
|
|
* @return array Updated quote row.
|
|
*/
|
|
function admin_update_quote(PDO $db, string $quote_id, array $data, string $admin_user = 'admin'): array
|
|
{
|
|
$quote = get_quote_by_id($db, $quote_id);
|
|
|
|
$changes = [];
|
|
$sets = [];
|
|
$params = [':id' => $quote_id];
|
|
|
|
if (isset($data['status']) && $data['status'] !== $quote['status']) {
|
|
if (!in_array($data['status'], VALID_STATUSES, true)) {
|
|
error_response("Invalid status: {$data['status']}", 400);
|
|
}
|
|
$old_status = $quote['status'];
|
|
$sets[] = 'status = :status';
|
|
$params[':status'] = $data['status'];
|
|
$changes[] = "status: {$old_status} -> {$data['status']}";
|
|
}
|
|
|
|
if (isset($data['expires_at'])) {
|
|
$sets[] = 'expires_at = :expires';
|
|
$params[':expires'] = $data['expires_at'];
|
|
$changes[] = "expires_at: {$data['expires_at']}";
|
|
}
|
|
|
|
if (!empty($sets)) {
|
|
$sets[] = 'updated_at = :now';
|
|
$params[':now'] = utc_now();
|
|
|
|
$sql = sprintf('UPDATE %s SET %s WHERE id = :id', tbl_quotes(), implode(', ', $sets));
|
|
$stmt = $db->prepare($sql);
|
|
$stmt->execute($params);
|
|
|
|
log_activity(
|
|
$db, $quote_id, 'admin_update',
|
|
"Admin update by {$admin_user}: " . implode(', ', $changes)
|
|
);
|
|
}
|
|
|
|
return get_quote_by_id($db, $quote_id);
|
|
}
|