query("SHOW COLUMNS FROM users LIKE 'avatar'"); if ($stmt->rowCount() === 0) { $pdo->exec("ALTER TABLE users ADD COLUMN avatar varchar(255) DEFAULT NULL"); } $stmt2 = $pdo->query("SHOW COLUMNS FROM branches LIKE 'avatar'"); if ($stmt2->rowCount() === 0) { $pdo->exec("ALTER TABLE branches ADD COLUMN avatar varchar(255) DEFAULT NULL"); } $stmt3 = $pdo->query("SHOW COLUMNS FROM sales_orders LIKE 'customer_id'"); if ($stmt3->rowCount() === 0) { $pdo->exec("ALTER TABLE sales_orders ADD COLUMN customer_id int(10) unsigned DEFAULT NULL"); } $stmt4 = $pdo->query("SHOW COLUMNS FROM sales_orders LIKE 'payment_status'"); if ($stmt4->rowCount() === 0) { $pdo->exec("ALTER TABLE sales_orders ADD COLUMN payment_status varchar(20) NOT NULL DEFAULT 'paid'"); } $stmt5 = $pdo->query("SHOW COLUMNS FROM sales_orders LIKE 'vat_amount'"); if ($stmt5->rowCount() === 0) { $pdo->exec("ALTER TABLE sales_orders ADD COLUMN vat_amount decimal(10,3) NOT NULL DEFAULT 0.000 AFTER subtotal"); } $stmt6 = $pdo->query("SHOW COLUMNS FROM sales_orders LIKE 'paid_amount'"); if ($stmt6->rowCount() === 0) { $pdo->exec("ALTER TABLE sales_orders ADD COLUMN paid_amount decimal(10,3) NOT NULL DEFAULT 0.000 AFTER total_amount"); } $stmt7 = $pdo->query("SHOW COLUMNS FROM sales_orders LIKE 'due_amount'"); if ($stmt7->rowCount() === 0) { $pdo->exec("ALTER TABLE sales_orders ADD COLUMN due_amount decimal(10,3) NOT NULL DEFAULT 0.000 AFTER paid_amount"); } $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_api_url', 'https://wablas.com/api/send-message'), ('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'); } $flagFileV6 = sys_get_temp_dir() . '/.schema_migrated_v6_' . md5(__DIR__); if (!file_exists($flagFileV6)) { $pdo = db(); $orderItemsStmt = $pdo->query("SELECT items_json FROM sales_orders WHERE status = 'order'"); $reservedBySku = []; foreach ($orderItemsStmt->fetchAll(PDO::FETCH_ASSOC) as $orderRow) { $orderItems = json_decode((string) ($orderRow['items_json'] ?? '[]'), true) ?: []; foreach ($orderItems as $item) { $sku = (string) ($item['sku'] ?? ''); $qty = (int) ($item['qty'] ?? 0); if ($sku === '' || $qty <= 0) { continue; } $reservedBySku[$sku] = ($reservedBySku[$sku] ?? 0) + $qty; } } if ($reservedBySku !== []) { $adjustStmt = $pdo->prepare("UPDATE items SET base_stock = base_stock - :qty WHERE sku = :sku"); foreach ($reservedBySku as $sku => $qty) { $adjustStmt->bindValue(':qty', $qty, PDO::PARAM_INT); $adjustStmt->bindValue(':sku', $sku); $adjustStmt->execute(); } } @file_put_contents($flagFileV6, '1'); } $flagFileV7 = sys_get_temp_dir() . '/.schema_migrated_v7_' . md5(__DIR__); if (!file_exists($flagFileV7)) { $pdo = db(); $hasOnlineOrdersTable = (bool) $pdo->query("SHOW TABLES LIKE 'online_orders'")->fetchColumn(); if ($hasOnlineOrdersTable) { $onlineOrderStmt = $pdo->query("SELECT items_json FROM online_orders WHERE status IN ('pending', 'accepted', 'completed')"); $reservedBySku = []; foreach ($onlineOrderStmt->fetchAll(PDO::FETCH_ASSOC) as $orderRow) { $orderItems = json_decode((string) ($orderRow['items_json'] ?? '[]'), true) ?: []; foreach ($orderItems as $item) { $sku = (string) ($item['sku'] ?? ''); $qty = (int) ($item['qty'] ?? 0); if ($sku === '' || $qty <= 0) { continue; } $reservedBySku[$sku] = ($reservedBySku[$sku] ?? 0) + $qty; } } if ($reservedBySku !== []) { $adjustStmt = $pdo->prepare("UPDATE items SET base_stock = base_stock - :qty WHERE sku = :sku"); foreach ($reservedBySku as $sku => $qty) { $adjustStmt->bindValue(':qty', $qty, PDO::PARAM_INT); $adjustStmt->bindValue(':sku', $sku); $adjustStmt->execute(); } } } @file_put_contents($flagFileV7, '1'); } $flagFileV8 = sys_get_temp_dir() . '/.schema_migrated_v8_' . md5(__DIR__); if (!file_exists($flagFileV8)) { $pdo = db(); $hasOnlineOrdersTable = (bool) $pdo->query("SHOW TABLES LIKE 'online_orders'")->fetchColumn(); if ($hasOnlineOrdersTable) { $requiredColumns = [ 'payment_method' => "ALTER TABLE online_orders ADD COLUMN payment_method varchar(30) NOT NULL DEFAULT 'pay_later' AFTER total_amount", 'payment_gateway' => "ALTER TABLE online_orders ADD COLUMN payment_gateway varchar(30) DEFAULT NULL AFTER payment_method", 'payment_status' => "ALTER TABLE online_orders ADD COLUMN payment_status varchar(20) NOT NULL DEFAULT 'unpaid' AFTER payment_gateway", 'gateway_session_id' => "ALTER TABLE online_orders ADD COLUMN gateway_session_id varchar(120) DEFAULT NULL AFTER payment_status", 'gateway_transaction_id' => "ALTER TABLE online_orders ADD COLUMN gateway_transaction_id varchar(120) DEFAULT NULL AFTER gateway_session_id", 'paid_at' => "ALTER TABLE online_orders ADD COLUMN paid_at datetime DEFAULT NULL AFTER gateway_transaction_id", ]; foreach ($requiredColumns as $column => $sql) { $exists = $pdo->query("SHOW COLUMNS FROM online_orders LIKE " . $pdo->quote($column))->fetchColumn(); if (!$exists) { $pdo->exec($sql); } } $pdo->exec("UPDATE online_orders SET payment_method = 'pay_later' WHERE payment_method IS NULL OR payment_method = ''"); $pdo->exec("UPDATE online_orders SET payment_status = CASE WHEN payment_method = 'pay_online' THEN 'pending' ELSE 'unpaid' END WHERE payment_status IS NULL OR payment_status = ''"); } $pdo->exec("INSERT IGNORE INTO settings (setting_key, setting_value) VALUES ('thawani_enabled', '0'), ('thawani_mode', 'sandbox'), ('thawani_publishable_key', ''), ('thawani_secret_key', ''), ('thawani_success_url', ''), ('thawani_cancel_url', ''), ('privacy_policy_content', ''), ('terms_conditions_content', '')"); @file_put_contents($flagFileV8, '1'); } $flagFileV9 = sys_get_temp_dir() . '/.schema_migrated_v9_' . md5(__DIR__); if (!file_exists($flagFileV9)) { $pdo = db(); $hasCustomersTable = (bool) $pdo->query("SHOW TABLES LIKE 'customers'")->fetchColumn(); if ($hasCustomersTable) { $hasPhoneNormalizedColumn = (bool) $pdo->query("SHOW COLUMNS FROM customers LIKE 'phone_normalized'")->fetchColumn(); if (!$hasPhoneNormalizedColumn) { $pdo->exec("ALTER TABLE customers ADD COLUMN phone_normalized varchar(8) DEFAULT NULL AFTER phone"); } $customerRows = $pdo->query("SELECT id, phone FROM customers ORDER BY id ASC")->fetchAll(PDO::FETCH_ASSOC); $updateCustomerPhoneStmt = $pdo->prepare("UPDATE customers SET phone = :phone, phone_normalized = :phone_normalized WHERE id = :id"); $seenNormalizedPhones = []; $duplicateNormalizedPhones = []; foreach ($customerRows as $customerRow) { $rawPhone = trim((string) ($customerRow['phone'] ?? '')); $normalizedPhone = normalize_oman_phone($rawPhone); $phoneForStorage = $normalizedPhone !== '' ? $normalizedPhone : ($rawPhone !== '' ? $rawPhone : null); $phoneNormalizedForStorage = $normalizedPhone !== '' ? $normalizedPhone : null; $customerId = (int) ($customerRow['id'] ?? 0); if ($phoneNormalizedForStorage !== null) { if (isset($seenNormalizedPhones[$phoneNormalizedForStorage]) && $seenNormalizedPhones[$phoneNormalizedForStorage] !== $customerId) { $duplicateNormalizedPhones[$phoneNormalizedForStorage] = true; } else { $seenNormalizedPhones[$phoneNormalizedForStorage] = $customerId; } } $updateCustomerPhoneStmt->bindValue(':phone', $phoneForStorage, $phoneForStorage === null ? PDO::PARAM_NULL : PDO::PARAM_STR); $updateCustomerPhoneStmt->bindValue(':phone_normalized', $phoneNormalizedForStorage, $phoneNormalizedForStorage === null ? PDO::PARAM_NULL : PDO::PARAM_STR); $updateCustomerPhoneStmt->bindValue(':id', $customerId, PDO::PARAM_INT); $updateCustomerPhoneStmt->execute(); } $hasPhoneNormalizedIndex = (bool) $pdo->query("SHOW INDEX FROM customers WHERE Key_name = 'uniq_customers_phone_normalized'")->fetchColumn(); if (!$hasPhoneNormalizedIndex) { if ($duplicateNormalizedPhones === []) { $pdo->exec("ALTER TABLE customers ADD UNIQUE KEY uniq_customers_phone_normalized (phone_normalized)"); } else { error_log('Skipped adding uniq_customers_phone_normalized because duplicate normalized customer phones still exist: ' . implode(', ', array_keys($duplicateNormalizedPhones))); } } } @file_put_contents($flagFileV9, '1'); } $flagFileV10 = sys_get_temp_dir() . '/.schema_migrated_v10_' . md5(__DIR__); if (!file_exists($flagFileV10)) { $pdo = db(); $hasItemsTable = (bool) $pdo->query("SHOW TABLES LIKE 'items'")->fetchColumn(); if ($hasItemsTable) { $hasNotesColumn = (bool) $pdo->query("SHOW COLUMNS FROM items LIKE 'notes'")->fetchColumn(); if (!$hasNotesColumn) { $pdo->exec("ALTER TABLE items ADD COLUMN notes text DEFAULT NULL AFTER image_url"); } } @file_put_contents($flagFileV10, '1'); } } catch (\Throwable $e) {} function get_settings(): array { static $settings = null; if ($settings === null) { $pdo = db(); try { $stmt = $pdo->query("SELECT setting_key, setting_value FROM settings"); $settings = []; while ($row = $stmt->fetch()) { $settings[$row['setting_key']] = $row['setting_value']; } } catch (Exception $e) { $settings = []; } } return $settings; } function get_setting(string $key, $default = '') { $settings = get_settings(); 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'; } date_default_timezone_set($app_tz); try { db()->exec("SET time_zone = '" . date('P') . "'"); } catch (Throwable $e) {} function app_name(): string { return get_setting('company_name_ar', 'حلوى الريامي') . ' | ' . get_setting('company_name_en', 'Al Riyami Sweets'); } function current_lang(): string { if (isset($_GET['lang']) && in_array($_GET['lang'], ['ar', 'en'], true)) { $_SESSION['lang'] = $_GET['lang']; } return $_SESSION['lang'] ?? 'ar'; } function is_rtl(): bool { return current_lang() === 'ar'; } function tr(string $ar, string $en): string { return current_lang() === 'ar' ? $ar : $en; } function h($value): string { return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8'); } function phone_digits(string $value): string { return preg_replace('/\D+/', '', $value) ?? ''; } function normalize_oman_phone(string $value): string { $digits = phone_digits($value); if ($digits === '') { return ''; } if (str_starts_with($digits, '00968')) { $digits = substr($digits, 5); } elseif (str_starts_with($digits, '968')) { $digits = substr($digits, 3); } if (strlen($digits) === 9 && $digits[0] === '0') { $digits = substr($digits, 1); } return strlen($digits) === 8 ? $digits : ''; } function phone_display(?string $value): string { $raw = trim((string) $value); if ($raw === '') { return ''; } $local = normalize_oman_phone($raw); return $local !== '' ? $local : $raw; } function customer_phone_exists(string $phone, ?int $excludeCustomerId = null): bool { $normalized = normalize_oman_phone($phone); if ($normalized === '') { return false; } $variants = array_values(array_unique([ $normalized, '968' . $normalized, '00968' . $normalized, '0' . $normalized, ])); $placeholders = []; foreach ($variants as $index => $_variant) { $placeholders[] = ':phone_' . $index; } $sql = 'SELECT id FROM customers WHERE phone IN (' . implode(', ', $placeholders) . ')'; if ($excludeCustomerId !== null && $excludeCustomerId > 0) { $sql .= ' AND id <> :exclude_customer_id'; } $sql .= ' LIMIT 1'; try { $stmt = db()->prepare($sql); foreach ($variants as $index => $variant) { $stmt->bindValue(':phone_' . $index, $variant); } if ($excludeCustomerId !== null && $excludeCustomerId > 0) { $stmt->bindValue(':exclude_customer_id', $excludeCustomerId, PDO::PARAM_INT); } $stmt->execute(); return (bool) $stmt->fetchColumn(); } catch (Throwable $e) { return false; } } function is_customer_phone_unique_violation(Throwable $e): bool { $message = strtolower($e->getMessage()); $code = (string) $e->getCode(); if (!in_array($code, ['23000', '1062'], true) && !str_contains($message, 'duplicate entry')) { return false; } return str_contains($message, 'uniq_customers_phone_normalized') || str_contains($message, 'phone_normalized'); } 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)) { $params['lang'] = current_lang(); } return http_build_query($params); } function url_for(string $path, array $params = []): string { $query = qs_with_lang($params); return $path . ($query ? ('?' . $query) : ''); } function request_scheme(): string { $https = (string) ($_SERVER['HTTPS'] ?? ''); if ($https !== '' && strtolower($https) !== 'off') { return 'https'; } $forwardedProto = trim((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')); if ($forwardedProto !== '') { return strtolower(explode(',', $forwardedProto)[0]); } return 'http'; } function app_base_url(): string { $host = trim((string) ($_SERVER['HTTP_X_FORWARDED_HOST'] ?? $_SERVER['HTTP_HOST'] ?? '')); if ($host === '') { $host = '127.0.0.1'; } return request_scheme() . '://' . $host; } function append_query_params(string $url, array $params): string { $parts = parse_url($url); $queryParams = []; if (!empty($parts['query'])) { parse_str($parts['query'], $queryParams); } foreach ($params as $key => $value) { if ($value === null || $value === '') { continue; } $queryParams[$key] = $value; } $rebuilt = ''; if (!empty($parts['scheme'])) { $rebuilt .= $parts['scheme'] . '://'; } if (!empty($parts['user'])) { $rebuilt .= $parts['user']; if (!empty($parts['pass'])) { $rebuilt .= ':' . $parts['pass']; } $rebuilt .= '@'; } if (!empty($parts['host'])) { $rebuilt .= $parts['host']; } if (!empty($parts['port'])) { $rebuilt .= ':' . $parts['port']; } $rebuilt .= $parts['path'] ?? ''; if ($queryParams !== []) { $rebuilt .= '?' . http_build_query($queryParams); } if (!empty($parts['fragment'])) { $rebuilt .= '#' . $parts['fragment']; } return $rebuilt; } function redirect_to(string $path, array $params = []): void { header('Location: ' . url_for($path, $params)); exit; } function set_flash(string $type, string $message): void { $_SESSION['flash'] = ['type' => $type, 'message' => $message]; } function pull_flash(): ?array { $flash = $_SESSION['flash'] ?? null; unset($_SESSION['flash']); return $flash; } function branches(): array { try { $db = db(); $stmt = $db->query("SELECT * FROM branches"); $res = $stmt->fetchAll(PDO::FETCH_ASSOC); if ($res) { $arr = []; foreach ($res as $row) { $arr[$row['code']] = $row; } return $arr; } } catch (Exception $e) { // Table might not exist yet } return [ 'muscat' => ['code' => 'muscat', 'name_ar' => 'فرع مسقط', 'name_en' => 'Muscat Branch', 'city_ar' => 'مسقط', 'city_en' => 'Muscat'], 'sohar' => ['code' => 'sohar', 'name_ar' => 'فرع صحار', 'name_en' => 'Sohar Branch', 'city_ar' => 'صحار', 'city_en' => 'Sohar'], 'nizwa' => ['code' => 'nizwa', 'name_ar' => 'فرع نزوى', 'name_en' => 'Nizwa Branch', 'city_ar' => 'نزوى', 'city_en' => 'Nizwa'], ]; } function branch_label(string $code): string { $branch = branches()[$code] ?? null; if (!$branch) { return $code; } return current_lang() === 'ar' ? $branch['name_ar'] : $branch['name_en']; } function role_label(string $role): string { return match ($role) { 'owner' => tr('مالك / مدير عام', 'Owner / Admin'), 'manager' => tr('مدير فرع', 'Branch Manager'), 'cashier' => tr('كاشير', 'Cashier'), default => $role, }; } function current_user(): ?array { return $_SESSION['auth_user'] ?? null; } function login_attempt(string $username, string $password): bool { require_once __DIR__ . "/../db/config.php"; $stmt = db()->prepare("SELECT * FROM users WHERE username = ?"); $stmt->execute([$username]); $user = $stmt->fetch(); if (!$user) { return false; } if (password_verify($password, $user["password"])) { $_SESSION["auth_user"] = $user; return true; } return false; } function logout_user(): void { unset($_SESSION['auth_user']); } function require_auth(): array { $user = current_user(); if (!$user) { set_flash('warning', tr('يرجى تسجيل الدخول أولاً.', 'Please sign in first.')); redirect_to('login.php'); } return $user; } function get_app_modules(): array { return ["pos" => ["name_ar" => "نقاط البيع", "name_en" => "POS", "actions" => ["show", "add"]], "normal_sale" => ["name_ar" => "فاتورة", "name_en" => "Invoice", "actions" => ["show", "add"]], "eid_orders" => ["name_ar" => "طلبات العيد", "name_en" => "Eid Orders", "actions" => ["show", "add", "edit"]], "online_orders" => ["name_ar" => "طلبات المتجر", "name_en" => "Online Orders", "actions" => ["show", "edit"]], "sales" => ["name_ar" => "المبيعات", "name_en" => "Sales", "actions" => ["show", "edit", "del"]], "debts" => ["name_ar" => "الديون", "name_en" => "Debts", "actions" => ["show", "edit"]], "purchases" => ["name_ar" => "المشتريات", "name_en" => "Purchases", "actions" => ["show", "add", "edit", "del"]], "stock" => ["name_ar" => "المخزون", "name_en" => "Stock", "actions" => ["show", "add", "edit", "del"]], "reports" => ["name_ar" => "التقارير", "name_en" => "Reports", "actions" => ["show"]], "customers" => ["name_ar" => "العملاء", "name_en" => "Customers", "actions" => ["show", "add", "edit", "del"]], "suppliers" => ["name_ar" => "الموردين", "name_en" => "Suppliers", "actions" => ["show", "add", "edit", "del"]], "categories" => ["name_ar" => "التصنيفات", "name_en" => "Categories", "actions" => ["show", "add", "edit", "del"]], "units" => ["name_ar" => "الوحدات", "name_en" => "Units", "actions" => ["show", "add", "edit", "del"]], "users" => ["name_ar" => "المستخدمين", "name_en" => "Users", "actions" => ["show", "add", "edit", "del"]], "settings" => ["name_ar" => "الإعدادات", "name_en" => "Settings", "actions" => ["show", "edit"]], "expense_categories" => ["name_ar" => "تصنيفات المصروفات", "name_en" => "Expense Categories", "actions" => ["show", "add", "edit", "del"]], "expenses" => ["name_ar" => "المصروفات", "name_en" => "Expenses", "actions" => ["show", "add", "edit", "del"]]]; } function has_permission(string $m, string $a = "show"): bool { $u = current_user(); if (!$u) return false; if ($u["role"] === "owner") return true; $p = !empty($u["permissions"]) ? (is_array($u["permissions"]) ? $u["permissions"] : json_decode($u["permissions"], true)) : []; return !empty($p[$m][$a]); } function require_permission(string $m, string $a = "show"): array { $u = require_auth(); if (!has_permission($m, $a)) { set_flash("warning", tr("ليس لديك صلاحية.", "You do not have permission.")); redirect_to("index.php"); } return $u; } function require_roles(array $roles): array { $user = require_auth(); if (!in_array($user['role'], $roles, true)) { set_flash('warning', tr('ليس لديك صلاحية للوصول إلى هذه الصفحة.', 'You do not have permission to access this page.')); redirect_to('index.php'); } return $user; } function get_user_branches($user): array { if (!$user) return []; if ($user['role'] === 'owner') return array_keys(branches()); $list = [$user['branch_code']]; if (!empty($user['allowed_branches'])) { $extra = explode(',', $user['allowed_branches']); foreach ($extra as $b) { $b = trim($b); if ($b) $list[] = $b; } } return array_unique($list); } function get_user_branches_assoc($user): array { if (!$user) return []; $all = branches(); $allowed = get_user_branches($user); $res = []; foreach ($allowed as $b) { if (isset($all[$b])) { $res[$b] = $all[$b]; } } return $res; } function can_access_branch(string $branchCode): bool { $user = current_user(); if (!$user) { return false; } if ($user['role'] === 'owner') { return true; } $allowed = get_user_branches($user); return in_array($branchCode, $allowed, true); } function catalog(): array { try { $db = db(); $stmt = $db->query("SELECT items.*, units.name_ar as u_name_ar, units.name_en as u_name_en FROM items LEFT JOIN units ON items.unit_id = units.id ORDER BY items.created_at DESC, items.id DESC"); $items = $stmt->fetchAll(PDO::FETCH_ASSOC); $catalog = []; foreach ($items as $item) { $catalog[$item["sku"]] = [ "id" => (int)($item["id"] ?? 0), "sku" => $item["sku"], "name_ar" => $item["name"], "name_en" => $item["name"], "price" => (float)$item["price"], "cost_price" => (float)($item["cost_price"] ?? 0), "base_stock" => (int)$item["base_stock"], "vat" => (float)$item["vat"], "category_id" => $item["category_id"], "in_catalog" => (int)($item["in_catalog"] ?? 0), "supplier_id" => $item["supplier_id"], "image_url" => $item["image_url"], "notes" => $item["notes"] ?? null, "created_at" => $item["created_at"] ?? null, "unit_id" => $item["unit_id"], "unit_ar" => $item["u_name_ar"] ?? "قطعة", "unit_en" => $item["u_name_en"] ?? "pcs" ]; } return $catalog; } catch (Throwable $e) { return []; } } function product_label(string $sku): string { $item = catalog()[$sku] ?? null; if (!$item) { return $sku; } return current_lang() === "ar" ? $item["name_ar"] : $item["name_en"]; } function currency(float $amount): string { return number_format($amount, 3) . ' ' . tr('ر.ع', 'OMR'); } function sale_mode_label(string $mode): string { return $mode === 'normal' ? tr('فاتورة', 'Invoice') : tr('بيع نقاط البيع', 'POS Sale'); } function round_money(float $amount): float { return round($amount, 3); } function sale_payment_breakdown(float $totalAmount, ?string $paymentMethod = null, $paidInput = null): array { $totalAmount = max(0.0, round_money($totalAmount)); $defaultPaid = $paymentMethod === 'pay_later' ? 0.0 : $totalAmount; if ($paidInput === null || $paidInput === '') { $paidAmount = $defaultPaid; } elseif (is_numeric($paidInput)) { $paidAmount = (float) $paidInput; } else { $paidAmount = $defaultPaid; } $paidAmount = min($totalAmount, max(0.0, round_money($paidAmount))); $dueAmount = max(0.0, round_money($totalAmount - $paidAmount)); $paymentStatus = $dueAmount <= 0.0005 ? 'paid' : ($paidAmount > 0 ? 'partial' : 'unpaid'); return [ 'paid_amount' => $paidAmount, 'due_amount' => $dueAmount, 'payment_status' => $paymentStatus, ]; } function sale_payment_summary(array $sale): array { $totalAmount = round_money((float) ($sale['total_amount'] ?? 0)); $storedPaid = $sale['paid_amount'] ?? null; $storedDue = $sale['due_amount'] ?? null; $paymentStatus = (string) ($sale['payment_status'] ?? ''); if ($storedPaid === null && $storedDue === null) { return sale_payment_breakdown($totalAmount, (string) ($sale['payment_method'] ?? ''), $paymentStatus === 'unpaid' ? 0 : $totalAmount); } $paidAmount = $storedPaid !== null ? max(0.0, round_money((float) $storedPaid)) : max(0.0, round_money($totalAmount - (float) $storedDue)); $dueAmount = $storedDue !== null ? max(0.0, round_money((float) $storedDue)) : max(0.0, round_money($totalAmount - $paidAmount)); if ($dueAmount > $totalAmount) { $dueAmount = $totalAmount; } if ($paidAmount > $totalAmount) { $paidAmount = $totalAmount; } if ($paymentStatus === '' || !in_array($paymentStatus, ['paid', 'partial', 'unpaid'], true)) { $paymentStatus = $dueAmount <= 0.0005 ? 'paid' : ($paidAmount > 0 ? 'partial' : 'unpaid'); } return [ 'paid_amount' => $paidAmount, 'due_amount' => $dueAmount, 'payment_status' => $paymentStatus, ]; } function payment_status_label(string $status): string { return match ($status) { 'partial' => tr('مدفوعة جزئياً', 'Partially Paid'), 'unpaid' => tr('غير مدفوعة', 'Unpaid'), default => tr('مدفوعة', 'Paid'), }; } function payment_status_badge_class(string $status): string { return match ($status) { 'partial' => 'bg-warning text-dark', 'unpaid' => 'bg-danger text-white', default => 'bg-success text-white', }; } function eid_delivery_status_options(): array { return [ 'pending' => tr('بانتظار التجهيز', 'Pending Prep'), 'preparing' => tr('قيد التجهيز', 'Preparing'), 'ready' => tr('جاهز', 'Ready'), 'delivered' => tr('تم التسليم', 'Delivered'), 'cancelled' => tr('ملغي', 'Cancelled'), ]; } function eid_delivery_status_label(string $status): string { $options = eid_delivery_status_options(); return $options[$status] ?? $status; } function eid_delivery_status_badge_class(string $status): string { return match ($status) { 'preparing' => 'bg-info text-dark', 'ready' => 'bg-primary text-white', 'delivered' => 'bg-success text-white', 'cancelled' => 'bg-danger text-white', default => 'bg-warning text-dark', }; } function sale_order_type_label(string $type): string { return match ($type) { 'eid' => tr('طلبات العيد', 'Eid Orders'), default => tr('بيع عادي', 'Standard Sale'), }; } function online_payment_method_label(string $method): string { return match ($method) { 'pay_online' => tr('ادفع أونلاين', 'Pay Online'), default => tr('ادفع لاحقاً', 'Pay Later'), }; } function online_payment_status_label(string $status): string { return match ($status) { 'paid' => tr('مدفوع', 'Paid'), 'pending' => tr('بانتظار الدفع', 'Awaiting Payment'), 'failed' => tr('فشل الدفع', 'Payment Failed'), 'cancelled' => tr('تم الإلغاء', 'Cancelled'), default => tr('غير مدفوع', 'Unpaid'), }; } function online_payment_status_badge_class(string $status): string { return match ($status) { 'paid' => 'bg-success text-white', 'pending' => 'bg-warning text-dark', 'failed' => 'bg-danger text-white', 'cancelled' => 'bg-secondary text-white', default => 'bg-danger-subtle text-danger-emphasis', }; } function thawani_is_enabled(): bool { return (string) get_setting('thawani_enabled', '0') === '1'; } function thawani_mode(): string { $mode = strtolower(trim((string) get_setting('thawani_mode', 'sandbox'))); return in_array($mode, ['sandbox', 'live'], true) ? $mode : 'sandbox'; } function thawani_checkout_base_url(): string { return thawani_mode() === 'live' ? 'https://checkout.thawani.om' : 'https://uatcheckout.thawani.om'; } function thawani_api_key(): string { return trim((string) get_setting('thawani_secret_key', '')); } function thawani_publishable_key(): string { return trim((string) get_setting('thawani_publishable_key', '')); } function thawani_is_configured(): bool { return thawani_is_enabled() && thawani_api_key() !== '' && thawani_publishable_key() !== ''; } function thawani_default_return_url(string $result): string { return app_base_url() . '/thawani_return.php?result=' . rawurlencode($result); } function thawani_success_url(): string { $custom = trim((string) get_setting('thawani_success_url', '')); return $custom !== '' ? $custom : thawani_default_return_url('success'); } function thawani_cancel_url(): string { $custom = trim((string) get_setting('thawani_cancel_url', '')); return $custom !== '' ? $custom : thawani_default_return_url('cancel'); } function thawani_build_products(array $items): array { $products = []; foreach ($items as $item) { $qty = max(1, (int) ($item['qty'] ?? 0)); $name = trim((string) ($item['name'] ?? $item['name_ar'] ?? $item['sku'] ?? 'Item')); $lineTotal = (float) ($item['line_total'] ?? 0); $vatAmount = (float) ($item['vat_amount'] ?? 0); $unitAmount = (int) round((($lineTotal + $vatAmount) / $qty) * 1000); $products[] = [ 'name' => $name, 'quantity' => $qty, 'unit_amount' => max(1, $unitAmount), ]; } return $products; } function thawani_call(string $method, string $path): array { $url = rtrim(thawani_checkout_base_url(), '/') . $path; $headers = [ 'Content-Type: application/json', 'Accept: application/json', 'thawani-api-key: ' . thawani_api_key(), ]; $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => strtoupper($method), CURLOPT_HTTPHEADER => $headers, CURLOPT_TIMEOUT => 30, ]); $raw = curl_exec($ch); $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch); if ($raw === false) { return ['success' => false, 'status' => $status, 'error' => $error !== '' ? $error : 'Unable to contact Thawani']; } $decoded = json_decode($raw, true); return [ 'success' => $status >= 200 && $status < 300, 'status' => $status, 'data' => is_array($decoded) ? $decoded : null, 'raw' => $raw, 'error' => $status >= 200 && $status < 300 ? '' : ('HTTP ' . $status), ]; } function thawani_create_checkout_session(int $orderId, array $order): array { if (!thawani_is_configured()) { return ['success' => false, 'error' => 'Thawani is not configured']; } $payload = [ 'client_reference_id' => 'online-order-' . $orderId, 'mode' => 'payment', 'products' => thawani_build_products($order['items'] ?? []), 'success_url' => append_query_params(thawani_success_url(), [ 'order_id' => $orderId, ]), 'cancel_url' => append_query_params(thawani_cancel_url(), [ 'order_id' => $orderId, ]), 'metadata' => [ 'order_id' => (string) $orderId, 'customer_name' => (string) ($order['customer_name'] ?? ''), 'customer_phone' => (string) ($order['customer_phone'] ?? ''), ], ]; $url = rtrim(thawani_checkout_base_url(), '/') . '/api/v1/checkout/session'; $headers = [ 'Content-Type: application/json', 'Accept: application/json', 'thawani-api-key: ' . thawani_api_key(), ]; $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), CURLOPT_HTTPHEADER => $headers, CURLOPT_TIMEOUT => 30, ]); $raw = curl_exec($ch); $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch); if ($raw === false) { return ['success' => false, 'status' => $status, 'error' => $error !== '' ? $error : 'Unable to contact Thawani']; } $decoded = json_decode($raw, true); $sessionId = (string) ($decoded['data']['session_id'] ?? $decoded['session_id'] ?? ''); if ($status < 200 || $status >= 300 || $sessionId === '') { $message = (string) ($decoded['message'] ?? $decoded['description'] ?? ('HTTP ' . $status)); return ['success' => false, 'status' => $status, 'error' => $message, 'data' => $decoded, 'raw' => $raw]; } return [ 'success' => true, 'status' => $status, 'session_id' => $sessionId, 'checkout_url' => rtrim(thawani_checkout_base_url(), '/') . '/pay/' . rawurlencode($sessionId) . '?key=' . rawurlencode(thawani_publishable_key()), 'data' => $decoded, 'raw' => $raw, ]; } function thawani_retrieve_session(string $sessionId): array { if ($sessionId === '') { return ['success' => false, 'error' => 'Missing session id']; } return thawani_call('GET', '/api/v1/checkout/session/' . rawurlencode($sessionId)); } function thawani_session_paid(array $response): bool { $data = (array) ($response['data']['data'] ?? $response['data'] ?? []); $candidates = [ strtolower((string) ($data['payment_status'] ?? '')), strtolower((string) ($data['status'] ?? '')), strtolower((string) ($data['paymentStatus'] ?? '')), strtolower((string) ($data['paymentStatusDescription'] ?? '')), ]; foreach ($candidates as $candidate) { if (in_array($candidate, ['paid', 'successful', 'success'], true)) { return true; } } return false; } function thawani_session_transaction_id(array $response): string { $data = (array) ($response['data']['data'] ?? $response['data'] ?? []); return (string) ($data['invoice'] ?? $data['payment_reference'] ?? $data['transaction_id'] ?? $data['reference'] ?? ''); } function apply_sale_payment(int $saleId, float $paymentAmount, bool $completeOrderWhenPaid = false): array { $sale = fetch_sale($saleId); if (!$sale) { throw new RuntimeException('Sale not found.'); } $summary = sale_payment_summary($sale); $paymentAmount = max(0.0, round_money($paymentAmount)); if ($paymentAmount <= 0) { throw new RuntimeException('Invalid payment amount.'); } if ($summary['due_amount'] <= 0.0005) { throw new RuntimeException('Invoice already paid.'); } $appliedAmount = min($paymentAmount, $summary['due_amount']); $newPaidAmount = round_money($summary['paid_amount'] + $appliedAmount); $newDueAmount = max(0.0, round_money((float) $sale['total_amount'] - $newPaidAmount)); $newPaymentStatus = $newDueAmount <= 0.0005 ? 'paid' : 'partial'; $oldSaleStatus = (string) ($sale['status'] ?? 'completed'); $newSaleStatus = $oldSaleStatus; if ($completeOrderWhenPaid && $newDueAmount <= 0.0005 && $newSaleStatus === 'order') { $newSaleStatus = 'completed'; } db()->beginTransaction(); try { sync_order_stock_reservation($sale['items'] ?? [], $oldSaleStatus, $sale['items'] ?? [], $newSaleStatus); $stmt = db()->prepare('UPDATE sales_orders SET paid_amount = :paid_amount, due_amount = :due_amount, payment_status = :payment_status, status = :status WHERE id = :id'); $stmt->execute([ ':paid_amount' => $newPaidAmount, ':due_amount' => $newDueAmount, ':payment_status' => $newPaymentStatus, ':status' => $newSaleStatus, ':id' => $saleId, ]); db()->commit(); } catch (Throwable $e) { if (db()->inTransaction()) { db()->rollBack(); } throw $e; } return [ 'applied_amount' => $appliedAmount, 'paid_amount' => $newPaidAmount, 'due_amount' => $newDueAmount, 'payment_status' => $newPaymentStatus, 'status' => $newSaleStatus, ]; } function wablas_is_enabled(): bool { return (string) get_setting('wablas_enabled', '1') !== '0'; } function wablas_has_credentials(?string $token = null, ?string $secretKey = null): bool { $token = $token ?? trim((string) get_setting('wablas_token', '')); $secretKey = $secretKey ?? trim((string) get_setting('wablas_secret_key', '')); return $token !== '' && $secretKey !== ''; } function wablas_is_configured(bool $requireEnabled = true): bool { return wablas_has_credentials() && (!$requireEnabled || wablas_is_enabled()); } function wablas_normalize_api_url(?string $value = null): string { $url = trim((string) ($value ?? get_setting('wablas_api_url', ''))); if ($url === '') { return 'https://wablas.com/api/send-message'; } if (!preg_match('#^https?://#i', $url)) { $url = 'https://' . ltrim($url, '/'); } $url = rtrim($url, '/'); if (!preg_match('#/api/(v2/)?send-message$#i', $url)) { $url .= '/api/send-message'; } return $url; } function wablas_order_status_label(string $status): string { return match ($status) { 'pending' => tr('قيد الانتظار', 'Pending'), 'accepted' => tr('مقبول', 'Accepted'), 'completed' => tr('مكتمل', 'Completed'), 'rejected' => tr('مرفوض', 'Rejected'), 'order' => tr('طلب مسبق', 'Pre-order'), default => $status, }; } 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) { 'created' => "مرحباً {customer_name}، تم استلام طلبك رقم #{order_id}. الحالة: {status_label} طريقة الدفع: {payment_method_label} حالة الدفع: {payment_status_label} الأصناف: {items_summary} الإجمالي: {total_amount} العنوان: {customer_address} شكراً لتسوقك معنا.", 'pending' => "مرحباً {customer_name}، طلبك رقم #{order_id} ما زال {status_label}. طريقة الدفع: {payment_method_label} حالة الدفع: {payment_status_label} الإجمالي: {total_amount} سنوافيك بأي تحديث جديد.", 'accepted' => "مرحباً {customer_name}، تم قبول طلبك رقم #{order_id}. طريقة الدفع: {payment_method_label} حالة الدفع: {payment_status_label} الإجمالي: {total_amount} سنبدأ التجهيز الآن.", 'completed' => "مرحباً {customer_name}، طلبك رقم #{order_id} أصبح {status_label}. طريقة الدفع: {payment_method_label} حالة الدفع: {payment_status_label} الإجمالي: {total_amount} شكراً لك.", 'rejected' => "مرحباً {customer_name}، نعتذر، تم تحديث طلبك رقم #{order_id} إلى {status_label}. طريقة الدفع: {payment_method_label} حالة الدفع: {payment_status_label} إذا رغبت بالمساعدة تواصل معنا.", default => "مرحباً {customer_name}، تم تحديث طلبك رقم #{order_id} إلى {status_label}.", }; } function wablas_render_template(string $template, array $vars): string { $message = $template; foreach ($vars as $key => $value) { $message = str_replace('{' . $key . '}', (string) $value, $message); } return preg_replace("/ {3,}/", " ", trim($message)) ?? trim($message); } function wablas_order_items_summary(array $order): string { $items = $order['items'] ?? null; if (!is_array($items)) { $items = json_decode((string) ($order['items_json'] ?? '[]'), true); } if (!is_array($items) || $items === []) { return ''; } $parts = []; foreach ($items as $item) { $name = (string) ($item['name'] ?? $item['name_ar'] ?? $item['sku'] ?? ''); $qty = (int) ($item['qty'] ?? 0); if ($name === '') { continue; } $parts[] = '- ' . $name . ($qty > 0 ? (' x' . $qty) : ''); } return implode(" ", $parts); } function wablas_order_template_vars(array $order): array { $status = (string) ($order['status'] ?? 'pending'); $paymentMethod = (string) ($order['payment_method'] ?? 'pay_later'); $paymentStatus = (string) ($order['payment_status'] ?? ($paymentMethod === 'pay_online' ? 'pending' : 'unpaid')); $itemsSummary = wablas_order_items_summary($order); if (trim($itemsSummary) === '') { $itemsSummary = '-'; } return [ 'order_id' => (string) ($order['id'] ?? ''), 'customer_name' => (string) ($order['customer_name'] ?? ''), 'customer_phone' => phone_display((string) ($order['customer_phone'] ?? '')), 'customer_address' => (string) ($order['customer_address'] ?? ''), 'status' => $status, 'status_label' => wablas_order_status_label($status), 'payment_method' => $paymentMethod, 'payment_method_label' => wablas_payment_method_label($paymentMethod), 'payment_status' => $paymentStatus, 'payment_status_label' => wablas_payment_status_label($paymentStatus), 'subtotal' => currency((float) ($order['subtotal'] ?? 0)), 'vat_amount' => currency((float) ($order['vat_amount'] ?? 0)), 'total_amount' => currency((float) ($order['total_amount'] ?? 0)), 'created_at' => (string) ($order['created_at'] ?? ''), 'items_summary' => $itemsSummary, ]; } 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'), 'pay_online' => tr('الدفع أونلاين', 'Pay online'), default => $method, }; } function wablas_payment_status_label(string $status): string { return match ($status) { 'paid' => tr('مدفوع', 'Paid'), 'partial' => tr('مدفوع جزئياً', 'Partial'), 'unpaid' => tr('غير مدفوع', 'Unpaid'), 'pending' => tr('بانتظار الدفع', 'Pending payment'), 'failed' => tr('فشل الدفع', 'Payment failed'), 'cancelled' => tr('تم إلغاء الدفع', 'Payment cancelled'), 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); $token = trim((string) ($options['token'] ?? get_setting('wablas_token', ''))); $secretKey = trim((string) ($options['secret_key'] ?? get_setting('wablas_secret_key', ''))); $ignoreEnabled = !empty($options['ignore_enabled']); $apiUrl = wablas_normalize_api_url($options['api_url'] ?? null); $message = trim($message); if ($localPhone === '') { return ['success' => false, 'error' => 'Invalid Oman phone number']; } if ($message === '') { return ['success' => false, 'error' => 'Message is empty']; } if (!$ignoreEnabled && !wablas_is_enabled()) { return ['success' => false, 'error' => 'WhatsApp sending is disabled']; } if (!wablas_has_credentials($token, $secretKey)) { return ['success' => false, 'error' => 'Wablas is not configured']; } $endpoint = $apiUrl; $payload = http_build_query([ 'phone' => '968' . $localPhone, 'message' => $message, ]); $headers = [ 'Authorization: ' . $token . '.' . $secretKey, 'Content-Type: application/x-www-form-urlencoded', ]; $responseBody = false; $httpCode = 0; if (function_exists('curl_init')) { $ch = curl_init($endpoint); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_HTTPHEADER => $headers, CURLOPT_TIMEOUT => 20, ]); $responseBody = curl_exec($ch); $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlError = curl_error($ch); curl_close($ch); if ($responseBody === false) { return ['success' => false, 'error' => $curlError ?: 'Failed to contact Wablas']; } } else { $context = stream_context_create([ 'http' => [ 'method' => 'POST', 'header' => implode(" ", $headers), 'content' => $payload, 'timeout' => 20, 'ignore_errors' => true, ], ]); $responseBody = @file_get_contents($endpoint, false, $context); $statusLine = $http_response_header[0] ?? ''; if (preg_match('/\s(\d{3})\s/', $statusLine, $m)) { $httpCode = (int) $m[1]; } } $decoded = json_decode((string) $responseBody, true); $success = $httpCode >= 200 && $httpCode < 300; $error = ''; if (!$success) { $error = (string) ($decoded['message'] ?? $decoded['data']['message'] ?? ''); if ($error === '') { $error = 'Wablas request failed'; } $error .= ' | URL: ' . $endpoint; } return [ 'success' => $success, 'status' => $httpCode, 'data' => $decoded, 'raw' => $responseBody, 'phone' => '968' . $localPhone, 'endpoint' => $endpoint, 'error' => $error, ]; } function wablas_notify_online_order(array $order, string $event): array { $template = trim((string) get_setting('wablas_template_' . $event, '')); if ($template === '') { $template = wablas_default_order_template($event); } $message = wablas_render_template($template, wablas_order_template_vars($order)); $result = wablas_send_message((string) ($order['customer_phone'] ?? ''), $message); if (empty($result['success'])) { error_log('Wablas notify failed for order #' . (string) ($order['id'] ?? '') . ': ' . (string) ($result['error'] ?? ('HTTP ' . ($result['status'] ?? '0')))); } return $result; } function fetch_online_order(int $orderId): ?array { if ($orderId <= 0) { return null; } try { $stmt = db()->prepare('SELECT * FROM online_orders WHERE id = :id LIMIT 1'); $stmt->bindValue(':id', $orderId, PDO::PARAM_INT); $stmt->execute(); $order = $stmt->fetch(PDO::FETCH_ASSOC); return $order ?: null; } catch (Throwable $e) { error_log('Failed to fetch online order #' . $orderId . ': ' . $e->getMessage()); return null; } } function wablas_notify_online_order_by_id(int $orderId, string $event): array { $order = fetch_online_order($orderId); if (!$order) { return ['success' => false, 'error' => 'Online order not found']; } return wablas_notify_online_order($order, $event); } function ensure_sales_table(): void { $sql = "CREATE TABLE IF NOT EXISTS sales_orders ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, receipt_no VARCHAR(50) NOT NULL UNIQUE, sale_mode VARCHAR(20) NOT NULL, branch_code VARCHAR(30) NOT NULL, cashier_username VARCHAR(60) NOT NULL, cashier_name VARCHAR(120) NOT NULL, role_name VARCHAR(40) NOT NULL, customer_id INT(10) UNSIGNED DEFAULT NULL, customer_name VARCHAR(120) DEFAULT NULL, payment_method VARCHAR(30) NOT NULL, payment_status VARCHAR(20) NOT NULL DEFAULT 'paid', items_json LONGTEXT NOT NULL, item_count INT UNSIGNED NOT NULL DEFAULT 0, subtotal DECIMAL(10,2) NOT NULL DEFAULT 0, vat_amount DECIMAL(10,3) NOT NULL DEFAULT 0.000, total_amount DECIMAL(10,2) NOT NULL DEFAULT 0, paid_amount DECIMAL(10,3) NOT NULL DEFAULT 0.000, due_amount DECIMAL(10,3) NOT NULL DEFAULT 0.000, status VARCHAR(20) NOT NULL DEFAULT 'completed', order_type VARCHAR(30) NOT NULL DEFAULT 'standard', delivery_status VARCHAR(30) NOT NULL DEFAULT 'pending', delivery_date DATE DEFAULT NULL, notes TEXT DEFAULT NULL, sale_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_sale_mode (sale_mode), INDEX idx_branch_code (branch_code), INDEX idx_sale_date (sale_date), INDEX idx_order_type (order_type), INDEX idx_delivery_date (delivery_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"; $pdo = db(); $pdo->exec($sql); $requiredColumns = [ 'order_type' => "ALTER TABLE sales_orders ADD COLUMN order_type varchar(30) NOT NULL DEFAULT 'standard' AFTER status", 'delivery_status' => "ALTER TABLE sales_orders ADD COLUMN delivery_status varchar(30) NOT NULL DEFAULT 'pending' AFTER order_type", 'delivery_date' => "ALTER TABLE sales_orders ADD COLUMN delivery_date date DEFAULT NULL AFTER delivery_status", 'eid_serial_no' => "ALTER TABLE sales_orders ADD COLUMN eid_serial_no int(10) unsigned DEFAULT NULL AFTER receipt_no", ]; foreach ($requiredColumns as $column => $columnSql) { $exists = $pdo->query("SHOW COLUMNS FROM sales_orders LIKE " . $pdo->quote($column))->fetchColumn(); if (!$exists) { $pdo->exec($columnSql); } } $requiredIndexes = [ 'idx_order_type' => 'ALTER TABLE sales_orders ADD INDEX idx_order_type (order_type)', 'idx_delivery_date' => 'ALTER TABLE sales_orders ADD INDEX idx_delivery_date (delivery_date)', ]; foreach ($requiredIndexes as $indexName => $indexSql) { $hasIndex = $pdo->query("SHOW INDEX FROM sales_orders WHERE Key_name = " . $pdo->quote($indexName))->fetchColumn(); if (!$hasIndex) { $pdo->exec($indexSql); } } $pdo->exec("UPDATE sales_orders SET order_type = 'standard' WHERE order_type IS NULL OR order_type = ''"); $pdo->exec("UPDATE sales_orders SET delivery_status = CASE WHEN COALESCE(status, 'completed') = 'completed' THEN 'delivered' ELSE 'pending' END WHERE delivery_status IS NULL OR delivery_status = ''"); $hasEidSerialColumn = $pdo->query("SHOW COLUMNS FROM sales_orders LIKE 'eid_serial_no'")->fetchColumn(); if ($hasEidSerialColumn) { $existingAssignedStmt = $pdo->query("SELECT COUNT(*) FROM sales_orders WHERE order_type = 'eid' AND eid_serial_no IS NOT NULL AND eid_serial_no > 0"); $existingAssigned = (int) $existingAssignedStmt->fetchColumn(); $nextSerial = $existingAssigned > 0 ? max(1, (int) $pdo->query("SELECT COALESCE(MAX(eid_serial_no), 0) + 1 FROM sales_orders WHERE order_type = 'eid'")->fetchColumn()) : 1; $missingStmt = $pdo->query("SELECT id FROM sales_orders WHERE order_type = 'eid' AND (eid_serial_no IS NULL OR eid_serial_no <= 0) ORDER BY id ASC"); $assignStmt = $pdo->prepare("UPDATE sales_orders SET eid_serial_no = :serial WHERE id = :id"); while ($row = $missingStmt->fetch()) { $assignStmt->bindValue(':serial', $nextSerial, PDO::PARAM_INT); $assignStmt->bindValue(':id', (int) $row['id'], PDO::PARAM_INT); $assignStmt->execute(); $nextSerial++; } } } function eid_serial_setting_key(): string { return 'eid_serial_sequence_next'; } function current_eid_serial_next(PDO $pdo): int { if (!$pdo->inTransaction()) { ensure_sales_table(); } $seedStmt = $pdo->query("SELECT COALESCE(MAX(eid_serial_no), 0) + 1 FROM sales_orders WHERE order_type = 'eid'"); $seed = max(1, (int) $seedStmt->fetchColumn()); $stmt = $pdo->prepare("SELECT setting_value FROM settings WHERE setting_key = :key LIMIT 1"); $stmt->bindValue(':key', eid_serial_setting_key()); $stmt->execute(); $current = (int) $stmt->fetchColumn(); return $current > 0 ? $current : $seed; } function reset_eid_serial_next(PDO $pdo, int $nextNumber = 1): void { $stmt = $pdo->prepare("INSERT INTO settings (setting_key, setting_value) VALUES (:key, :value) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)"); $stmt->bindValue(':key', eid_serial_setting_key()); $stmt->bindValue(':value', (string) max(1, $nextNumber)); $stmt->execute(); } function next_eid_serial_no(PDO $pdo): int { if (!$pdo->inTransaction()) { ensure_sales_table(); } $settingKey = eid_serial_setting_key(); $seedValue = current_eid_serial_next($pdo); $seedStmt = $pdo->prepare("INSERT INTO settings (setting_key, setting_value) VALUES (:key, :value) ON DUPLICATE KEY UPDATE setting_key = VALUES(setting_key)"); $seedStmt->bindValue(':key', $settingKey); $seedStmt->bindValue(':value', (string) $seedValue); $seedStmt->execute(); $selectStmt = $pdo->prepare("SELECT setting_value FROM settings WHERE setting_key = :key FOR UPDATE"); $selectStmt->bindValue(':key', $settingKey); $selectStmt->execute(); $nextNumber = max(1, (int) $selectStmt->fetchColumn()); $updateStmt = $pdo->prepare("UPDATE settings SET setting_value = :next_value WHERE setting_key = :key"); $updateStmt->bindValue(':next_value', (string) ($nextNumber + 1)); $updateStmt->bindValue(':key', $settingKey); $updateStmt->execute(); return $nextNumber; } function eid_serial_label($serial): string { return 'E-' . str_pad((string) max(1, (int) $serial), 4, '0', STR_PAD_LEFT); } function create_sale(array $data): int { ensure_sales_table(); $pdo = db(); $ownsTransaction = !$pdo->inTransaction(); if ($ownsTransaction) { $pdo->beginTransaction(); } try { $receiptNo = isset($data['receipt_no']) && trim((string) $data['receipt_no']) !== '' ? trim((string) $data['receipt_no']) : next_receipt_code($pdo); $orderType = trim((string) ($data['order_type'] ?? 'standard')); if (!in_array($orderType, ['standard', 'eid'], true)) { $orderType = 'standard'; } $eidSerialNo = $orderType === 'eid' ? next_eid_serial_no($pdo) : null; $defaultDeliveryStatus = (($data['status'] ?? 'completed') === 'completed') ? 'delivered' : 'pending'; $deliveryStatus = trim((string) ($data['delivery_status'] ?? $defaultDeliveryStatus)); if (!array_key_exists($deliveryStatus, eid_delivery_status_options())) { $deliveryStatus = $defaultDeliveryStatus; } $deliveryDate = isset($data['delivery_date']) ? trim((string) $data['delivery_date']) : ''; if ($deliveryDate !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $deliveryDate)) { $deliveryDate = ''; } $stmt = $pdo->prepare('INSERT INTO sales_orders (receipt_no, eid_serial_no, sale_mode, branch_code, cashier_username, cashier_name, role_name, customer_id, customer_name, payment_method, payment_status, items_json, item_count, subtotal, vat_amount, total_amount, paid_amount, due_amount, status, order_type, delivery_status, delivery_date, notes, sale_date) VALUES (:receipt_no, :eid_serial_no, :sale_mode, :branch_code, :cashier_username, :cashier_name, :role_name, :customer_id, :customer_name, :payment_method, :payment_status, :items_json, :item_count, :subtotal, :vat_amount, :total_amount, :paid_amount, :due_amount, :status, :order_type, :delivery_status, :delivery_date, :notes, NOW())'); $stmt->bindValue(':receipt_no', $receiptNo); $stmt->bindValue(':eid_serial_no', $eidSerialNo, $eidSerialNo === null ? PDO::PARAM_NULL : PDO::PARAM_INT); $stmt->bindValue(':sale_mode', $data['sale_mode']); $stmt->bindValue(':branch_code', $data['branch_code']); $stmt->bindValue(':cashier_username', $data['cashier_username']); $stmt->bindValue(':cashier_name', $data['cashier_name']); $stmt->bindValue(':role_name', $data['role_name']); $customerId = isset($data['customer_id']) && $data['customer_id'] !== '' ? (int) $data['customer_id'] : null; $stmt->bindValue(':customer_id', $customerId, $customerId === null ? PDO::PARAM_NULL : PDO::PARAM_INT); $stmt->bindValue(':customer_name', $data['customer_name']); $stmt->bindValue(':payment_method', $data['payment_method']); $stmt->bindValue(':payment_status', $data['payment_status'] ?? 'paid'); $stmt->bindValue(':items_json', json_encode($data['items'], JSON_UNESCAPED_UNICODE)); $stmt->bindValue(':item_count', $data['item_count'], PDO::PARAM_INT); $stmt->bindValue(':subtotal', $data['subtotal']); $stmt->bindValue(':vat_amount', $data['vat_amount'] ?? 0.0); $stmt->bindValue(':total_amount', $data['total_amount']); $stmt->bindValue(':paid_amount', $data['paid_amount'] ?? $data['total_amount']); $stmt->bindValue(':due_amount', $data['due_amount'] ?? 0.0); $stmt->bindValue(':status', $data['status'] ?? 'completed'); $stmt->bindValue(':order_type', $orderType); $stmt->bindValue(':delivery_status', $deliveryStatus); $stmt->bindValue(':delivery_date', $deliveryDate !== '' ? $deliveryDate : null, $deliveryDate !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL); $stmt->bindValue(':notes', $data['notes']); $stmt->execute(); $saleId = (int) $pdo->lastInsertId(); sync_order_stock_reservation([], 'completed', $data['items'] ?? [], (string) ($data['status'] ?? 'completed')); if ($ownsTransaction && $pdo->inTransaction()) { $pdo->commit(); } return $saleId; } catch (Throwable $e) { if ($ownsTransaction && $pdo->inTransaction()) { $pdo->rollBack(); } throw $e; } } function sale_item_quantities(array $items): array { $quantities = []; foreach ($items as $item) { $sku = (string) ($item['sku'] ?? ''); $qty = (int) ($item['qty'] ?? 0); if ($sku === '' || $qty <= 0) { continue; } $quantities[$sku] = ($quantities[$sku] ?? 0) + $qty; } return $quantities; } function adjust_item_base_stock(array $stockDeltaBySku): void { if ($stockDeltaBySku === []) { return; } $stmt = db()->prepare('UPDATE items SET base_stock = base_stock + :stock_delta WHERE sku = :sku'); foreach ($stockDeltaBySku as $sku => $delta) { if ($sku === '' || $delta === 0) { continue; } $stmt->bindValue(':stock_delta', $delta, PDO::PARAM_INT); $stmt->bindValue(':sku', (string) $sku); $stmt->execute(); } } function sync_order_stock_reservation(array $oldItems, string $oldStatus, array $newItems, string $newStatus): void { $previousReserved = $oldStatus === 'order' ? sale_item_quantities($oldItems) : []; $nextReserved = $newStatus === 'order' ? sale_item_quantities($newItems) : []; $stockDeltaBySku = []; foreach ($previousReserved as $sku => $qty) { $stockDeltaBySku[$sku] = ($stockDeltaBySku[$sku] ?? 0) + $qty; } foreach ($nextReserved as $sku => $qty) { $stockDeltaBySku[$sku] = ($stockDeltaBySku[$sku] ?? 0) - $qty; } adjust_item_base_stock($stockDeltaBySku); } function online_order_reserves_stock(string $status): bool { return in_array($status, ['pending', 'accepted', 'completed'], true); } function sync_online_order_stock_reservation(array $oldItems, string $oldStatus, array $newItems, string $newStatus): void { $previousReserved = online_order_reserves_stock($oldStatus) ? sale_item_quantities($oldItems) : []; $nextReserved = online_order_reserves_stock($newStatus) ? sale_item_quantities($newItems) : []; $stockDeltaBySku = []; foreach ($previousReserved as $sku => $qty) { $stockDeltaBySku[$sku] = ($stockDeltaBySku[$sku] ?? 0) + $qty; } foreach ($nextReserved as $sku => $qty) { $stockDeltaBySku[$sku] = ($stockDeltaBySku[$sku] ?? 0) - $qty; } adjust_item_base_stock($stockDeltaBySku); } function base_sales_query_filters(array &$params, ?string $mode = null, ?string $branch = null): string { $sql = ' WHERE 1=1 '; if ($mode) { $sql .= ' AND sale_mode = :sale_mode '; $params[':sale_mode'] = $mode; } if ($branch) { $sql .= ' AND branch_code = :branch_code '; $params[':branch_code'] = $branch; } $user = current_user(); if ($user && $user['role'] !== 'owner') { $ubranches = get_user_branches($user); if (empty($ubranches)) { $sql .= ' AND 1=0 '; // No branches allowed } else { $namedParams = []; foreach ($ubranches as $i => $ub) { $key = ':v_branch_' . $i; $namedParams[] = $key; $params[$key] = $ub; } $sql .= ' AND branch_code IN (' . implode(', ', $namedParams) . ') '; } } return $sql; } function fetch_sales(?string $mode = null, ?string $branch = null, int $limit = 50): array { ensure_sales_table(); $params = []; $sql = 'SELECT * FROM sales_orders' . base_sales_query_filters($params, $mode, $branch) . ' ORDER BY sale_date DESC LIMIT :limit'; $stmt = db()->prepare($sql); foreach ($params as $key => $value) { $stmt->bindValue($key, $value); } $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); $stmt->execute(); $rows = $stmt->fetchAll(); foreach ($rows as &$row) { $row['items'] = json_decode((string) $row['items_json'], true) ?: []; } return $rows; } function fetch_sale(int $id): ?array { ensure_sales_table(); $stmt = db()->prepare('SELECT * FROM sales_orders WHERE id = :id LIMIT 1'); $stmt->bindValue(':id', $id, PDO::PARAM_INT); $stmt->execute(); $sale = $stmt->fetch(); if (!$sale) { return null; } if (!can_access_branch((string) $sale['branch_code'])) { return null; } $sale['items'] = json_decode((string) $sale['items_json'], true) ?: []; return $sale; } function fetch_all_sales_for_scope(): array { ensure_sales_table(); $params = []; $sql = 'SELECT * FROM sales_orders' . base_sales_query_filters($params); $stmt = db()->prepare($sql); foreach ($params as $key => $value) { $stmt->bindValue($key, $value); } $stmt->execute(); $rows = $stmt->fetchAll(); foreach ($rows as &$row) { $row['items'] = json_decode((string) $row['items_json'], true) ?: []; } return $rows; } function dashboard_metrics(): array { $sales = fetch_all_sales_for_scope(); $today = date('Y-m-d'); $todaySales = 0; $todayRevenue = 0.0; $normalCount = 0; $posCount = 0; foreach ($sales as $sale) { if (str_starts_with((string) $sale['sale_date'], $today)) { $todaySales++; $todayRevenue += (float) $sale['total_amount']; } if (($sale['sale_mode'] ?? '') === 'normal') { $normalCount++; } else { $posCount++; } } return [ 'today_sales' => $todaySales, 'today_revenue' => $todayRevenue, 'pos_count' => $posCount, 'normal_count' => $normalCount, 'recent' => array_slice(fetch_sales(null, null, 6), 0, 6), ]; } function report_metrics(): array { $sales = fetch_all_sales_for_scope(); $branchTotals = []; $paymentTotals = []; $productTotals = []; $monthlyTotals = []; $gross = 0.0; $totalVat = 0.0; foreach ($sales as $sale) { $branch = $sale['branch_code']; $branchTotals[$branch] = ($branchTotals[$branch] ?? 0.0) + (float) $sale['total_amount']; $payment = $sale['payment_method']; $paymentTotals[$payment] = ($paymentTotals[$payment] ?? 0.0) + (float) $sale['total_amount']; $gross += (float) $sale['total_amount']; $totalVat += (float) $sale['vat_amount']; $month = substr((string)$sale['sale_date'], 0, 7); $monthlyTotals[$month] = ($monthlyTotals[$month] ?? 0.0) + (float) $sale['total_amount']; foreach ($sale['items'] as $item) { $sku = (string) ($item['sku'] ?? ''); $qty = (int) ($item['qty'] ?? 0); $productTotals[$sku] = ($productTotals[$sku] ?? 0) + $qty; } } arsort($branchTotals); arsort($paymentTotals); arsort($productTotals); ksort($monthlyTotals); return [ 'gross' => $gross, 'total_vat' => $totalVat, 'branch_totals' => $branchTotals, 'payment_totals' => $paymentTotals, 'product_totals' => $productTotals, 'monthly_totals' => $monthlyTotals, 'sales_count' => count($sales), ]; } function stock_snapshot(): array { $catalog = catalog(); $sold = []; foreach (fetch_all_sales_for_scope() as $sale) { if ((string) ($sale['status'] ?? 'completed') === 'order') { continue; } foreach ($sale['items'] as $item) { $sku = (string) ($item['sku'] ?? ''); $sold[$sku] = ($sold[$sku] ?? 0) + (int) ($item['qty'] ?? 0); } } $rows = []; foreach ($catalog as $sku => $item) { $base = (int) $item['base_stock']; $used = $sold[$sku] ?? 0; $rows[] = [ 'sku' => $sku, 'name' => current_lang() === 'ar' ? $item['name_ar'] : $item['name_en'], 'base_stock' => $base, 'sold' => $used, 'available' => max(0, $base - $used), 'price' => $item['price'], 'cost_price' => $item['cost_price'] ?? 0, 'category_id' => $item['category_id'], 'supplier_id' => $item['supplier_id'], 'unit_id' => $item['unit_id'], 'image_url' => $item['image_url'], 'in_catalog' => $item['in_catalog'] ?? 0, 'notes' => $item['notes'] ?? null, 'vat' => $item['vat'], ]; } usort($rows, static fn(array $a, array $b): int => $a['available'] <=> $b['available']); return $rows; } function module_cards(): array { return [ ['title_ar' => 'نقاط البيع', 'title_en' => 'POS Sale', 'path' => 'pos.php', 'desc_ar' => 'إتمام البيع السريع مع تحديث السجل.', 'desc_en' => 'Fast checkout with instant sales logging.'], ['title_ar' => 'فاتورة', 'title_en' => 'Invoice', 'path' => 'normal_sale.php', 'desc_ar' => 'فاتورة يدوية مع العميل والملاحظات.', 'desc_en' => 'Manual invoice flow with customer details and notes.'], ['title_ar' => 'المبيعات', 'title_en' => 'Sales Ledger', 'path' => 'sales.php', 'desc_ar' => 'قائمة الفواتير مع التفاصيل والفرز.', 'desc_en' => 'Invoice list with filters and detail views.'], ['title_ar' => 'طلبات العيد', 'title_en' => 'Eid Orders', 'path' => 'eid_orders.php', 'desc_ar' => 'قائمة مخصصة لطلبات العيد مع فلاتر التاريخ والتجهيز.', 'desc_en' => 'Dedicated Eid orders list with prep and date filters.'], ['title_ar' => 'المخزون', 'title_en' => 'Stock', 'path' => 'stock.php', 'desc_ar' => 'قراءة فورية للمخزون الحالي والتنبيهات.', 'desc_en' => 'Live stock snapshot and low-stock indicators.'], ['title_ar' => 'المشتريات', 'title_en' => 'Purchases', 'path' => 'purchases.php', 'desc_ar' => 'واجهة مبدئية لاستلام الموردين بين الفروع.', 'desc_en' => 'Starter receiving board for suppliers and branches.'], ['title_ar' => 'التقارير', 'title_en' => 'Reports', 'path' => 'reports.php', 'desc_ar' => 'مبيعات اليوم، الفروع، وأفضل الأصناف.', 'desc_en' => 'Daily sales, branch totals, and best sellers.'], ['title_ar' => 'المستخدمون والأدوار', 'title_en' => 'Users & Roles', 'path' => 'users.php', 'desc_ar' => 'صلاحيات منفصلة للمالك والمدير والكاشير.', 'desc_en' => 'Separate access for owner, manager, and cashier.'], ]; } function purchase_pipeline(): array { return [ ['supplier' => 'Oman Dates Co.', 'reference' => 'PO-24019', 'branch' => 'muscat', 'status' => tr('بانتظار الاستلام', 'Pending Receiving'), 'eta' => '2026-04-20'], ['supplier' => 'Golden Nuts', 'reference' => 'PO-24023', 'branch' => 'sohar', 'status' => tr('في الطريق', 'In Transit'), 'eta' => '2026-04-21'], ['supplier' => 'Saffron House', 'reference' => 'PO-24026', 'branch' => 'nizwa', 'status' => tr('جاهز للفحص', 'Ready for QC'), 'eta' => '2026-04-22'], ]; } function next_receipt_code(PDO $pdo): string { $settingKey = 'invoice_sequence_next'; $seedStmt = $pdo->prepare("INSERT INTO settings (setting_key, setting_value) VALUES (:key, '1') ON DUPLICATE KEY UPDATE setting_key = VALUES(setting_key)"); $seedStmt->bindValue(':key', $settingKey); $seedStmt->execute(); $selectStmt = $pdo->prepare("SELECT setting_value FROM settings WHERE setting_key = :key FOR UPDATE"); $selectStmt->bindValue(':key', $settingKey); $selectStmt->execute(); $nextNumber = max(1, (int) $selectStmt->fetchColumn()); $existsStmt = $pdo->prepare('SELECT 1 FROM sales_orders WHERE receipt_no = :receipt_no LIMIT 1'); while (true) { $candidate = (string) $nextNumber; $existsStmt->bindValue(':receipt_no', $candidate); $existsStmt->execute(); if (!$existsStmt->fetchColumn()) { break; } $nextNumber++; } $updateStmt = $pdo->prepare("UPDATE settings SET setting_value = :next_value WHERE setting_key = :key"); $updateStmt->bindValue(':next_value', (string) ($nextNumber + 1)); $updateStmt->bindValue(':key', $settingKey); $updateStmt->execute(); return (string) $nextNumber; } function receipt_code(): string { $pdo = db(); $ownsTransaction = !$pdo->inTransaction(); if ($ownsTransaction) { $pdo->beginTransaction(); } try { $receiptNo = next_receipt_code($pdo); if ($ownsTransaction && $pdo->inTransaction()) { $pdo->commit(); } return $receiptNo; } catch (Throwable $e) { if ($ownsTransaction && $pdo->inTransaction()) { $pdo->rollBack(); } throw $e; } } function purchase_reference_code(): string { return (string) random_int(100000, 999999); } function create_purchase(array $data): int { db()->beginTransaction(); try { $stmt = db()->prepare("INSERT INTO purchase_orders (reference_no, branch_code, user_username, user_name, role_name, supplier_name, items_json, item_count, subtotal, vat_amount, total_amount, status, notes, purchase_date) VALUES (:reference_no, :branch_code, :user_username, :user_name, :role_name, :supplier_name, :items_json, :item_count, :subtotal, :vat_amount, :total_amount, :status, :notes, NOW())"); $stmt->bindValue(":reference_no", $data["reference_no"]); $stmt->bindValue(":branch_code", $data["branch_code"]); $stmt->bindValue(":user_username", $data["user_username"]); $stmt->bindValue(":user_name", $data["user_name"]); $stmt->bindValue(":role_name", $data["role_name"]); $stmt->bindValue(":supplier_name", $data["supplier_name"]); $stmt->bindValue(":items_json", json_encode($data["items"], JSON_UNESCAPED_UNICODE)); $stmt->bindValue(":item_count", $data["item_count"], PDO::PARAM_INT); $stmt->bindValue(":subtotal", $data["subtotal"]); $stmt->bindValue(":vat_amount", $data["vat_amount"] ?? 0.0); $stmt->bindValue(":total_amount", $data["total_amount"]); $stmt->bindValue(":status", $data["status"] ?? "completed"); $stmt->bindValue(":notes", $data["notes"]); $stmt->execute(); $purchaseId = (int) db()->lastInsertId(); // Update stock foreach ($data["items"] as $item) { $qty = (int) $item["qty"]; $sku = $item["sku"]; $updateStmt = db()->prepare("UPDATE items SET base_stock = base_stock + :qty, cost_price = :price WHERE sku = :sku"); $updateStmt->bindValue(":qty", $qty, PDO::PARAM_INT); $updateStmt->bindValue(":price", $item["price"]); $updateStmt->bindValue(":sku", $sku); $updateStmt->execute(); } db()->commit(); return $purchaseId; } catch (Throwable $e) { db()->rollBack(); 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();