39728-vm/includes/app.php
2026-05-01 18:08:28 +00:00

2384 lines
86 KiB
PHP

<?php
declare(strict_types=1);
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
require_once __DIR__ . '/../db/config.php';
// Auto-migrate newly added columns
try {
$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'");
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",
];
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 = ''");
}
function create_sale(array $data): int
{
ensure_sales_table();
$pdo = db();
$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';
}
$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, 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, :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(':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'));
$pdo->commit();
return $saleId;
} catch (Throwable $e) {
if ($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();