From c8919d7836fad7282e3fe1b1274dc6ebe52acfce Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Tue, 21 Apr 2026 04:05:16 +0000 Subject: [PATCH] Autosave: 20260421-040526 --- api/settings.php | 20 ++ api/wablas_daily_report.php | 39 +++ includes/app.php | 500 ++++++++++++++++++++++++++++++++++- includes/footer_settings.php | 391 ++++++++++++++++++++++----- includes/sale_form.php | 2 + pos.php | 2 + reports.php | 46 +--- 7 files changed, 898 insertions(+), 102 deletions(-) create mode 100644 api/wablas_daily_report.php diff --git a/api/settings.php b/api/settings.php index 45ead73..e881ae1 100644 --- a/api/settings.php +++ b/api/settings.php @@ -15,6 +15,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { 'timezone', 'company_name_ar', 'company_name_en', 'vat_percentage', 'company_vat_number', 'company_phone', 'company_email', 'company_address', 'wablas_enabled', 'wablas_token', 'wablas_secret_key', + 'wablas_invoice_recipients', 'wablas_report_recipients', + 'wablas_daily_auto_send', 'wablas_daily_auto_time', 'wablas_daily_auto_last_date', + 'wablas_template_invoice', 'wablas_template_daily_report', 'wablas_template_created', 'wablas_template_pending', 'wablas_template_accepted', 'wablas_template_completed', 'wablas_template_rejected', 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_secure', 'mail_from', 'mail_from_name' ]; @@ -33,6 +36,23 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $_POST['company_phone'] = $companyPhone; } + foreach (['wablas_invoice_recipients', 'wablas_report_recipients'] as $phoneListKey) { + $parsed = wablas_parse_phone_list((string) ($_POST[$phoneListKey] ?? '')); + if (!empty($parsed['invalid'])) { + set_flash('danger', tr('يوجد رقم واتساب غير صالح في الحقل.', 'There is an invalid WhatsApp number in the field.') . ' ' . implode(', ', $parsed['invalid'])); + $referer = $_SERVER['HTTP_REFERER'] ?? '../index.php'; + header('Location: ' . $referer); + exit; + } + $_POST[$phoneListKey] = implode(',', $parsed['phones']); + } + + $_POST['wablas_daily_auto_time'] = wablas_format_time_setting((string) ($_POST['wablas_daily_auto_time'] ?? '21:00')); + if (!isset($_POST['wablas_daily_auto_send'])) { + $_POST['wablas_daily_auto_send'] = '0'; + } + unset($_POST['wablas_daily_auto_last_date']); + foreach ($keys as $key) { if (isset($_POST[$key])) { $value = is_string($_POST[$key]) ? trim($_POST[$key]) : $_POST[$key]; diff --git a/api/wablas_daily_report.php b/api/wablas_daily_report.php new file mode 100644 index 0000000..1865ca3 --- /dev/null +++ b/api/wablas_daily_report.php @@ -0,0 +1,39 @@ + 'daily']); +} + +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + redirect_to('../reports.php', ['tab' => 'daily']); +} + +$reportDate = trim((string) ($_POST['date'] ?? date('Y-m-d'))); +$branch = trim((string) ($_POST['branch'] ?? '')); + +if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $reportDate)) { + set_flash('danger', tr('تاريخ التقرير غير صالح.', 'Invalid report date.')); + redirect_to('../reports.php', ['tab' => 'daily']); +} + +try { + $result = wablas_send_daily_report($reportDate, $branch !== '' ? $branch : null); + if (!empty($result['success'])) { + set_flash('success', tr('تم إرسال ملخص التقرير اليومي عبر واتساب.', 'Daily summary report sent via WhatsApp.')); + } else { + $error = (string) ($result['error'] ?? tr('تعذر إرسال التقرير عبر واتساب.', 'Could not send the WhatsApp report.')); + set_flash('danger', tr('فشل إرسال التقرير عبر واتساب.', 'Failed to send the WhatsApp report.') . ' ' . $error); + } +} catch (Throwable $e) { + set_flash('danger', tr('فشل إرسال التقرير عبر واتساب.', 'Failed to send the WhatsApp report.') . ' ' . $e->getMessage()); +} + +redirect_to('../reports.php', [ + 'tab' => 'daily', + 'date' => $reportDate, + 'branch' => $branch, +]); diff --git a/includes/app.php b/includes/app.php index bbc57f1..569693c 100644 --- a/includes/app.php +++ b/includes/app.php @@ -9,7 +9,7 @@ require_once __DIR__ . '/../db/config.php'; // Auto-migrate newly added columns try { - $flagFile = sys_get_temp_dir() . '/.schema_migrated_v3_' . md5(__DIR__); + $flagFile = sys_get_temp_dir() . '/.schema_migrated_v4_' . md5(__DIR__); if (!file_exists($flagFile)) { $pdo = db(); $stmt = $pdo->query("SHOW COLUMNS FROM users LIKE 'avatar'"); @@ -43,8 +43,23 @@ try { $pdo->exec("UPDATE sales_orders SET paid_amount = CASE WHEN payment_status = 'unpaid' THEN 0 ELSE total_amount END WHERE paid_amount IS NULL OR paid_amount = 0"); $pdo->exec("UPDATE sales_orders SET due_amount = GREATEST(total_amount - paid_amount, 0)"); $pdo->exec("UPDATE sales_orders SET payment_status = CASE WHEN due_amount <= 0.0005 THEN 'paid' WHEN paid_amount > 0 THEN 'partial' ELSE 'unpaid' END"); + $pdo->exec("INSERT IGNORE INTO settings (setting_key, setting_value) VALUES + ('wablas_invoice_recipients', ''), + ('wablas_report_recipients', ''), + ('wablas_template_invoice', ''), + ('wablas_template_daily_report', '')"); @file_put_contents($flagFile, '1'); } + + $flagFileV5 = sys_get_temp_dir() . '/.schema_migrated_v5_' . md5(__DIR__); + if (!file_exists($flagFileV5)) { + $pdo = db(); + $pdo->exec("INSERT IGNORE INTO settings (setting_key, setting_value) VALUES + ('wablas_daily_auto_send', '0'), + ('wablas_daily_auto_time', '21:00'), + ('wablas_daily_auto_last_date', '')"); + @file_put_contents($flagFileV5, '1'); + } } catch (\Throwable $e) {} @@ -73,6 +88,41 @@ function get_setting(string $key, $default = '') return $settings[$key] ?? $default; } +function get_setting_non_empty(string $key, $default = '') +{ + $value = get_setting($key, null); + if ($value === null) { + return $default; + } + + if (is_string($value) && trim($value) === '') { + return $default; + } + + return $value; +} + +function save_setting_value(string $key, string $value): void +{ + try { + $stmt = db()->prepare("INSERT INTO settings (setting_key, setting_value) VALUES (:key, :value) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)"); + $stmt->bindValue(':key', $key); + $stmt->bindValue(':value', $value); + $stmt->execute(); + } catch (Throwable $e) { + } +} + +function wablas_format_time_setting(string $value, string $default = '21:00'): string +{ + $value = trim($value); + if (!preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $value)) { + return $default; + } + + return $value; +} + $app_tz = get_setting('timezone', 'UTC'); if (empty($app_tz)) { $app_tz = 'UTC'; @@ -149,6 +199,40 @@ function phone_display(?string $value): string return $local !== '' ? ('968 ' . $local) : $raw; } +function wablas_parse_phone_list(string $value): array +{ + $parts = preg_split('/[\s,;،]+/', trim($value)) ?: []; + $phones = []; + $invalid = []; + + foreach ($parts as $part) { + $part = trim((string) $part); + if ($part === '') { + continue; + } + + $normalized = normalize_oman_phone($part); + if ($normalized === '') { + $invalid[] = $part; + continue; + } + + $phones[$normalized] = $normalized; + } + + return [ + 'phones' => array_values($phones), + 'invalid' => $invalid, + ]; +} + +function wablas_phone_list_for_input(string $value): string +{ + $parsed = wablas_parse_phone_list($value); + return implode(" +", $parsed['phones']); +} + function qs_with_lang(array $params = []): string { if (!isset($params['lang']) || !in_array($params['lang'], ['ar', 'en'], true)) { @@ -526,6 +610,47 @@ function wablas_order_status_label(string $status): string }; } + +function wablas_default_invoice_template(): string +{ + return "🧾 فاتورة جديدة #{receipt_no} +الفرع: {branch_name} +النوع: {sale_mode_label} +العميل: {customer_name} +الهاتف: {customer_phone} +الدفع: {payment_method_label} / {payment_status_label} + +الأصناف: +{items_summary} + +قبل الضريبة: {subtotal} +الضريبة: {vat_amount} +الإجمالي: {total_amount} + +الكاشير: {cashier_name} +التاريخ: {sale_date}"; +} + +function wablas_default_daily_report_template(): string +{ + return "📊 ملخص المبيعات اليومي +التاريخ: {report_date} +الفرع: {branch_name} +عدد الفواتير: {invoice_count} +إجمالي المبيعات: {total_sales} + +حسب الموظف: +{seller_summary} + +حسب الفرع: +{outlet_summary} + +حسب الدفع: +{payment_summary} + +وقت الإرسال: {generated_at}"; +} + function wablas_default_order_template(string $event): string { return match ($event) { @@ -604,6 +729,336 @@ function wablas_order_template_vars(array $order): array ]; } + +function wablas_sale_items_summary(array $sale): string +{ + $items = $sale['items'] ?? null; + if (!is_array($items)) { + $items = json_decode((string) ($sale['items_json'] ?? '[]'), true); + } + if (!is_array($items) || $items === []) { + return '-'; + } + + $parts = []; + foreach ($items as $item) { + $name = (string) ($item['name'] ?? $item['name_ar'] ?? $item['name_en'] ?? $item['sku'] ?? ''); + $qty = (int) ($item['qty'] ?? 0); + $lineTotal = isset($item['line_total']) ? currency((float) $item['line_total']) : ''; + if ($name === '') { + continue; + } + $line = '- ' . $name; + if ($qty > 0) { + $line .= ' x' . $qty; + } + if ($lineTotal !== '') { + $line .= ' = ' . $lineTotal; + } + $parts[] = $line; + } + + return $parts ? implode(" +", $parts) : '-'; +} + +function wablas_payment_method_label(string $method): string +{ + return match ($method) { + 'cash' => tr('كاش', 'Cash'), + 'card' => tr('بطاقة', 'Card'), + 'transfer', 'bank' => tr('تحويل', 'Transfer'), + 'pay_later' => tr('آجل', 'Pay later'), + default => $method, + }; +} + +function wablas_payment_status_label(string $status): string +{ + return match ($status) { + 'paid' => tr('مدفوع', 'Paid'), + 'partial' => tr('مدفوع جزئياً', 'Partial'), + 'unpaid' => tr('غير مدفوع', 'Unpaid'), + default => $status, + }; +} + +function wablas_customer_phone_by_id(?int $customerId): string +{ + if (!$customerId) { + return ''; + } + + try { + $stmt = db()->prepare('SELECT phone FROM customers WHERE id = :id LIMIT 1'); + $stmt->bindValue(':id', $customerId, PDO::PARAM_INT); + $stmt->execute(); + $phone = (string) $stmt->fetchColumn(); + return normalize_oman_phone($phone); + } catch (Throwable $e) { + return ''; + } +} + +function wablas_invoice_template_vars(array $sale): array +{ + $customerName = trim((string) ($sale['customer_name'] ?? '')); + if ($customerName === '') { + $customerName = tr('عميل نقدي', 'Walk-in customer'); + } + + $customerPhone = wablas_customer_phone_by_id(isset($sale['customer_id']) ? (int) $sale['customer_id'] : null); + $paymentStatus = (string) ($sale['payment_status'] ?? 'paid'); + $saleDateRaw = (string) ($sale['sale_date'] ?? $sale['created_at'] ?? ''); + $saleDate = $saleDateRaw !== '' ? date('Y-m-d H:i', strtotime($saleDateRaw)) : ''; + + return [ + 'sale_id' => (string) ($sale['id'] ?? ''), + 'receipt_no' => (string) ($sale['receipt_no'] ?? ''), + 'branch_name' => branch_label((string) ($sale['branch_code'] ?? '')), + 'sale_mode' => (string) ($sale['sale_mode'] ?? ''), + 'sale_mode_label' => sale_mode_label((string) ($sale['sale_mode'] ?? '')), + 'customer_name' => $customerName, + 'customer_phone' => $customerPhone !== '' ? phone_display($customerPhone) : '-', + 'payment_method' => (string) ($sale['payment_method'] ?? ''), + 'payment_method_label' => wablas_payment_method_label((string) ($sale['payment_method'] ?? '')), + 'payment_status' => $paymentStatus, + 'payment_status_label' => wablas_payment_status_label($paymentStatus), + 'cashier_name' => (string) ($sale['cashier_name'] ?? ''), + 'subtotal' => currency((float) ($sale['subtotal'] ?? 0)), + 'vat_amount' => currency((float) ($sale['vat_amount'] ?? 0)), + 'total_amount' => currency((float) ($sale['total_amount'] ?? 0)), + 'paid_amount' => currency((float) ($sale['paid_amount'] ?? 0)), + 'due_amount' => currency((float) ($sale['due_amount'] ?? 0)), + 'sale_date' => $saleDate, + 'notes' => (string) ($sale['notes'] ?? ''), + 'items_summary' => wablas_sale_items_summary($sale), + ]; +} + +function daily_sales_breakdown(string $reportDate, ?string $branch = null): array +{ + ensure_sales_table(); + + $params = []; + $where = base_sales_query_filters($params, null, $branch ?: null); + $where .= " AND DATE(sale_date) = :rdate AND status != 'order'"; + $params[':rdate'] = $reportDate; + + $dailyTotals = [ + 'seller' => [], + 'outlet' => [], + 'payment' => [], + 'total' => 0.0, + 'invoice_count' => 0, + 'date' => $reportDate, + 'branch' => $branch ?: '', + ]; + + $sql = "SELECT cashier_name, SUM(total_amount) as total FROM sales_orders" . $where . " GROUP BY cashier_name"; + $stmt = db()->prepare($sql); + $stmt->execute($params); + $dailyTotals['seller'] = $stmt->fetchAll(PDO::FETCH_KEY_PAIR) ?: []; + + $sql = "SELECT branch_code, SUM(total_amount) as total FROM sales_orders" . $where . " GROUP BY branch_code"; + $stmt = db()->prepare($sql); + $stmt->execute($params); + $dailyTotals['outlet'] = $stmt->fetchAll(PDO::FETCH_KEY_PAIR) ?: []; + + $sql = "SELECT payment_method, SUM(total_amount) as total FROM sales_orders" . $where . " GROUP BY payment_method"; + $stmt = db()->prepare($sql); + $stmt->execute($params); + $dailyTotals['payment'] = $stmt->fetchAll(PDO::FETCH_KEY_PAIR) ?: []; + + $sql = "SELECT COUNT(*) as invoice_count, COALESCE(SUM(total_amount), 0) as total FROM sales_orders" . $where; + $stmt = db()->prepare($sql); + $stmt->execute($params); + $row = $stmt->fetch() ?: []; + $dailyTotals['invoice_count'] = (int) ($row['invoice_count'] ?? 0); + $dailyTotals['total'] = (float) ($row['total'] ?? 0); + + return $dailyTotals; +} + +function wablas_format_summary_lines(array $rows, callable $formatter): string +{ + if ($rows === []) { + return '- ' . tr('لا يوجد', 'None'); + } + + $lines = []; + foreach ($rows as $key => $amount) { + $lines[] = '- ' . $formatter((string) $key) . ': ' . currency((float) $amount); + } + return implode(" +", $lines); +} + +function wablas_daily_report_template_vars(array $dailyTotals): array +{ + return [ + 'report_date' => (string) ($dailyTotals['date'] ?? date('Y-m-d')), + 'branch_name' => !empty($dailyTotals['branch']) ? branch_label((string) $dailyTotals['branch']) : tr('جميع الفروع', 'All Branches'), + 'invoice_count' => (string) ((int) ($dailyTotals['invoice_count'] ?? 0)), + 'total_sales' => currency((float) ($dailyTotals['total'] ?? 0)), + 'seller_summary' => wablas_format_summary_lines((array) ($dailyTotals['seller'] ?? []), static fn(string $value): string => $value !== '' ? $value : tr('غير محدد', 'Unknown')), + 'outlet_summary' => wablas_format_summary_lines((array) ($dailyTotals['outlet'] ?? []), static fn(string $value): string => branch_label($value)), + 'payment_summary' => wablas_format_summary_lines((array) ($dailyTotals['payment'] ?? []), static fn(string $value): string => wablas_payment_method_label($value)), + 'generated_at' => date('Y-m-d H:i'), + ]; +} + +function wablas_invoice_preview_vars(): array +{ + try { + $sales = fetch_sales(null, null, 1); + if (!empty($sales[0]) && is_array($sales[0])) { + return wablas_invoice_template_vars($sales[0]); + } + } catch (Throwable $e) { + // Ignore preview lookup failures and use fallback sample data below. + } + + return wablas_invoice_template_vars([ + 'id' => 1001, + 'receipt_no' => 'INV-1001', + 'branch_code' => 'main', + 'sale_mode' => 'normal', + 'customer_name' => tr('عميل تجريبي', 'Sample Customer'), + 'payment_method' => 'cash', + 'payment_status' => 'paid', + 'cashier_name' => tr('الموظف', 'Cashier'), + 'subtotal' => 25.000, + 'vat_amount' => 1.250, + 'total_amount' => 26.250, + 'paid_amount' => 26.250, + 'due_amount' => 0.000, + 'sale_date' => date('Y-m-d H:i:s'), + 'items' => [ + ['name' => tr('منتج 1', 'Item 1'), 'qty' => 2, 'line_total' => 10.000], + ['name' => tr('منتج 2', 'Item 2'), 'qty' => 1, 'line_total' => 15.000], + ], + ]); +} + +function wablas_daily_report_preview_vars(): array +{ + try { + $dailyTotals = daily_sales_breakdown(date('Y-m-d')); + $hasData = (int) ($dailyTotals['invoice_count'] ?? 0) > 0 + || (float) ($dailyTotals['total'] ?? 0) > 0 + || !empty($dailyTotals['seller']) + || !empty($dailyTotals['outlet']) + || !empty($dailyTotals['payment']); + if ($hasData) { + return wablas_daily_report_template_vars($dailyTotals); + } + } catch (Throwable $e) { + // Ignore preview lookup failures and use fallback sample data below. + } + + return wablas_daily_report_template_vars([ + 'date' => date('Y-m-d'), + 'branch' => 'main', + 'invoice_count' => 8, + 'total' => 182.750, + 'seller' => [ + tr('الموظف 1', 'Cashier 1') => 95.500, + tr('الموظف 2', 'Cashier 2') => 87.250, + ], + 'outlet' => ['main' => 182.750], + 'payment' => ['cash' => 120.000, 'card' => 62.750], + ]); +} + +function wablas_send_to_multiple_recipients(array $phones, string $message, array $options = []): array +{ + $results = []; + $sent = 0; + $firstError = ''; + foreach ($phones as $phone) { + $result = wablas_send_message($phone, $message, $options); + $results[] = $result; + if (!empty($result['success'])) { + $sent++; + } elseif ($firstError === '') { + $firstError = (string) ($result['error'] ?? ('HTTP ' . (string) ($result['status'] ?? '0'))); + } + } + + return [ + 'success' => $sent > 0 && $sent === count($phones), + 'attempted' => count($phones), + 'sent' => $sent, + 'failed' => count($phones) - $sent, + 'results' => $results, + 'message' => $message, + 'error' => $firstError, + ]; +} + +function wablas_notify_sale_invoice(int $saleId): array +{ + $sale = fetch_sale($saleId); + if (!$sale) { + return ['success' => false, 'attempted' => 0, 'error' => 'Sale not found']; + } + + $customerPhone = wablas_customer_phone_by_id(isset($sale['customer_id']) ? (int) $sale['customer_id'] : null); + if ($customerPhone === '') { + return ['success' => false, 'attempted' => 0, 'error' => 'No customer WhatsApp phone on invoice']; + } + + $template = trim((string) get_setting_non_empty('wablas_template_invoice', wablas_default_invoice_template())); + if ($template === '') { + $template = wablas_default_invoice_template(); + } + + $message = wablas_render_template($template, wablas_invoice_template_vars($sale)); + $result = wablas_send_message($customerPhone, $message); + if (empty($result['success'])) { + error_log('Wablas invoice notify failed for sale #' . $saleId . ' customer ' . $customerPhone); + } + + return [ + 'success' => !empty($result['success']), + 'attempted' => 1, + 'sent' => !empty($result['success']) ? 1 : 0, + 'failed' => !empty($result['success']) ? 0 : 1, + 'results' => [ + [ + 'phone' => $customerPhone, + 'success' => !empty($result['success']), + 'response' => $result, + ], + ], + 'customer_phone' => $customerPhone, + 'error' => empty($result['success']) ? (string) ($result['error'] ?? 'Failed to send invoice message') : '', + ]; +} + +function wablas_send_daily_report(string $reportDate, ?string $branch = null): array +{ + $phones = wablas_parse_phone_list((string) get_setting('wablas_report_recipients', ''))['phones']; + if ($phones === []) { + return ['success' => false, 'attempted' => 0, 'error' => 'No daily report recipients configured']; + } + + $dailyTotals = daily_sales_breakdown($reportDate, $branch); + $template = trim((string) get_setting_non_empty('wablas_template_daily_report', wablas_default_daily_report_template())); + if ($template === '') { + $template = wablas_default_daily_report_template(); + } + + $message = wablas_render_template($template, wablas_daily_report_template_vars($dailyTotals)); + $result = wablas_send_to_multiple_recipients($phones, $message); + $result['report'] = $dailyTotals; + if (($result['failed'] ?? 0) > 0) { + error_log('Wablas daily report send failed for date ' . $reportDate . ' branch ' . (string) $branch); + } + return $result; +} + function wablas_send_message(string $phone, string $message, array $options = []): array { $localPhone = normalize_oman_phone($phone); @@ -1032,3 +1487,46 @@ function create_purchase(array $data): int throw $e; } } + + +function wablas_daily_auto_is_enabled(): bool +{ + return (string) get_setting('wablas_daily_auto_send', '0') === '1'; +} + +function wablas_auto_send_daily_report_if_due(): void +{ + static $checked = false; + if ($checked) { + return; + } + $checked = true; + + if (!wablas_daily_auto_is_enabled() || !wablas_is_configured()) { + return; + } + + $phones = wablas_parse_phone_list((string) get_setting('wablas_report_recipients', ''))['phones']; + if ($phones === []) { + return; + } + + $today = date('Y-m-d'); + $currentTime = date('H:i'); + $scheduledTime = wablas_format_time_setting((string) get_setting('wablas_daily_auto_time', '21:00')); + $lastSentDate = trim((string) get_setting('wablas_daily_auto_last_date', '')); + + if ($lastSentDate === $today || $currentTime < $scheduledTime) { + return; + } + + $result = wablas_send_daily_report($today, null); + if (!empty($result['success'])) { + save_setting_value('wablas_daily_auto_last_date', $today); + return; + } + + error_log('Wablas scheduled daily report failed for date ' . $today . ' at ' . $scheduledTime . ': ' . (string) ($result['error'] ?? 'unknown error')); +} + +wablas_auto_send_daily_report_if_due(); diff --git a/includes/footer_settings.php b/includes/footer_settings.php index b49ac14..47f1851 100644 --- a/includes/footer_settings.php +++ b/includes/footer_settings.php @@ -1,12 +1,31 @@ +