39728-vm/includes/app.php
2026-04-19 02:30:10 +00:00

477 lines
16 KiB
PHP

<?php
declare(strict_types=1);
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
require_once __DIR__ . '/../db/config.php';
date_default_timezone_set('UTC');
function app_name(): string
{
return 'حلوى الريامي | 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 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
{
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 demo_users(): array
{
return [
'owner' => [
'username' => 'owner',
'password' => 'owner123',
'role' => 'owner',
'branch_code' => 'muscat',
'name_ar' => 'مالك النظام',
'name_en' => 'System Owner',
],
'manager_muscat' => [
'username' => 'manager_muscat',
'password' => 'manager123',
'role' => 'manager',
'branch_code' => 'muscat',
'name_ar' => 'مدير فرع مسقط',
'name_en' => 'Muscat Branch Manager',
],
'cashier_sohar' => [
'username' => 'cashier_sohar',
'password' => 'cashier123',
'role' => 'cashier',
'branch_code' => 'sohar',
'name_ar' => 'كاشير فرع صحار',
'name_en' => 'Sohar Cashier',
],
];
}
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
{
$users = demo_users();
if (!isset($users[$username])) {
return false;
}
$user = $users[$username];
if ($user['password'] !== $password) {
return false;
}
$_SESSION['auth_user'] = $user;
return true;
}
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 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 can_access_branch(string $branchCode): bool
{
$user = current_user();
if (!$user) {
return false;
}
if ($user['role'] === 'owner') {
return true;
}
return $user['branch_code'] === $branchCode;
}
function catalog(): array
{
return [
'baklava_box' => ['sku' => 'baklava_box', 'name_ar' => 'بقلاوة مشكلة', 'name_en' => 'Mixed Baklava Box', 'price' => 18.50, 'base_stock' => 72, 'unit_ar' => 'علبة', 'unit_en' => 'box'],
'date_truffles' => ['sku' => 'date_truffles', 'name_ar' => 'ترافل التمر', 'name_en' => 'Date Truffles', 'price' => 9.25, 'base_stock' => 120, 'unit_ar' => 'علبة', 'unit_en' => 'box'],
'saffron_maamoul' => ['sku' => 'saffron_maamoul', 'name_ar' => 'معمول الزعفران', 'name_en' => 'Saffron Maamoul', 'price' => 7.80, 'base_stock' => 88, 'unit_ar' => 'صندوق', 'unit_en' => 'pack'],
'pistachio_bites' => ['sku' => 'pistachio_bites', 'name_ar' => 'لقيمات الفستق', 'name_en' => 'Pistachio Bites', 'price' => 11.40, 'base_stock' => 64, 'unit_ar' => 'علبة', 'unit_en' => 'box'],
'halwa_classic' => ['sku' => 'halwa_classic', 'name_ar' => 'حلوى عمانية كلاسيك', 'name_en' => 'Classic Omani Halwa', 'price' => 6.20, 'base_stock' => 150, 'unit_ar' => 'عبوة', 'unit_en' => 'jar'],
'gift_tin' => ['sku' => 'gift_tin', 'name_ar' => 'علبة هدايا فاخرة', 'name_en' => 'Premium Gift Tin', 'price' => 24.00, 'base_stock' => 36, 'unit_ar' => 'علبة', 'unit_en' => 'tin'],
];
}
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, 2) . ' ' . tr('ر.ع', 'OMR');
}
function sale_mode_label(string $mode): string
{
return $mode === 'normal' ? tr('بيع عادي', 'Normal Sale') : tr('بيع نقاط البيع', 'POS Sale');
}
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_name VARCHAR(120) DEFAULT NULL,
payment_method VARCHAR(30) NOT NULL,
items_json LONGTEXT NOT NULL,
item_count INT UNSIGNED NOT NULL DEFAULT 0,
subtotal DECIMAL(10,2) NOT NULL DEFAULT 0,
total_amount DECIMAL(10,2) NOT NULL DEFAULT 0,
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_name, payment_method, items_json, item_count, subtotal, total_amount, notes, sale_date)
VALUES
(:receipt_no, :sale_mode, :branch_code, :cashier_username, :cashier_name, :role_name, :customer_name, :payment_method, :items_json, :item_count, :subtotal, :total_amount, :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_name', $data['customer_name']);
$stmt->bindValue(':payment_method', $data['payment_method']);
$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(':total_amount', $data['total_amount']);
$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') {
$sql .= ' AND branch_code = :viewer_branch ';
$params[':viewer_branch'] = $user['branch_code'];
}
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 = [];
$gross = 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'];
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);
return [
'gross' => $gross,
'branch_totals' => $branchTotals,
'payment_totals' => $paymentTotals,
'product_totals' => $productTotals,
'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' => (float) $item['price'],
];
}
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 'AR-' . date('ymd-His') . '-' . random_int(100, 999);
}