1035 lines
38 KiB
PHP
1035 lines
38 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_v3_' . 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");
|
|
@file_put_contents($flagFile, '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;
|
|
}
|
|
|
|
$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 !== '' ? ('968 ' . $local) : $raw;
|
|
}
|
|
|
|
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 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" => "Normal Sale", "actions" => ["show", "add"]], "sales" => ["name_ar" => "المبيعات", "name_en" => "Sales", "actions" => ["show", "edit", "del"]], "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"],
|
|
"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('بيع عادي', 'Normal Sale') : 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 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';
|
|
$newSaleStatus = (string) ($sale['status'] ?? 'completed');
|
|
if ($completeOrderWhenPaid && $newDueAmount <= 0.0005 && $newSaleStatus === 'order') {
|
|
$newSaleStatus = 'completed';
|
|
}
|
|
|
|
$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,
|
|
]);
|
|
|
|
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_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_order_template(string $event): string
|
|
{
|
|
return match ($event) {
|
|
'created' => "مرحباً {customer_name}، تم استلام طلبك رقم #{order_id}.
|
|
الحالة: {status_label}
|
|
الإجمالي: {total_amount}
|
|
العنوان: {customer_address}
|
|
شكراً لتسوقك معنا.",
|
|
'pending' => "مرحباً {customer_name}، طلبك رقم #{order_id} ما زال {status_label}.
|
|
الإجمالي: {total_amount}
|
|
سنوافيك بأي تحديث جديد.",
|
|
'accepted' => "مرحباً {customer_name}، تم قبول طلبك رقم #{order_id}.
|
|
الإجمالي: {total_amount}
|
|
سنبدأ التجهيز الآن.",
|
|
'completed' => "مرحباً {customer_name}، طلبك رقم #{order_id} أصبح {status_label}.
|
|
الإجمالي: {total_amount}
|
|
شكراً لك.",
|
|
'rejected' => "مرحباً {customer_name}، نعتذر، تم تحديث طلبك رقم #{order_id} إلى {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');
|
|
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),
|
|
'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' => wablas_order_items_summary($order),
|
|
];
|
|
}
|
|
|
|
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']);
|
|
$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 = 'https://wablas.com/api/send-message';
|
|
$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;
|
|
|
|
return [
|
|
'success' => $success,
|
|
'status' => $httpCode,
|
|
'data' => $decoded,
|
|
'raw' => $responseBody,
|
|
'phone' => '968' . $localPhone,
|
|
];
|
|
}
|
|
|
|
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 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',
|
|
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)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci";
|
|
|
|
db()->exec($sql);
|
|
}
|
|
|
|
function create_sale(array $data): int
|
|
{
|
|
ensure_sales_table();
|
|
|
|
$stmt = db()->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, 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, :notes, NOW())');
|
|
|
|
$stmt->bindValue(':receipt_no', $data['receipt_no']);
|
|
$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']);
|
|
$stmt->bindValue(':customer_id', $data['customer_id'] ?? 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(':notes', $data['notes']);
|
|
$stmt->execute();
|
|
|
|
return (int) db()->lastInsertId();
|
|
}
|
|
|
|
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) {
|
|
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'],
|
|
'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' => 'Normal Sale', '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' => '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 receipt_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;
|
|
}
|
|
}
|