39528-vm/app.php
2026-04-09 09:46:40 +00:00

1075 lines
38 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
require_once __DIR__ . '/db/config.php';
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
date_default_timezone_set('UTC');
function app_name(): string {
return $_SERVER['PROJECT_NAME'] ?? 'ERP Core';
}
function project_description_default(): string {
return $_SERVER['PROJECT_DESCRIPTION'] ?? 'ERP operations starter for customers, suppliers, products, inventory, purchases, sales, and manufacturing.';
}
function e(?string $value): string {
return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
}
function csrf_token(): string {
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(24));
}
return $_SESSION['csrf_token'];
}
function verify_csrf(): void {
$token = (string)($_POST['csrf_token'] ?? '');
if (!hash_equals((string)($_SESSION['csrf_token'] ?? ''), $token)) {
http_response_code(419);
exit('Invalid CSRF token.');
}
}
function set_flash(string $type, string $message): void {
$_SESSION['flash'] = ['type' => $type, 'message' => $message];
}
function get_flash(): ?array {
if (empty($_SESSION['flash'])) {
return null;
}
$flash = $_SESSION['flash'];
unset($_SESSION['flash']);
return is_array($flash) ? $flash : null;
}
function json_payload(?string $payload): array {
if (!$payload) {
return [];
}
$decoded = json_decode($payload, true);
return is_array($decoded) ? $decoded : [];
}
function parse_csv_list(string $raw): array {
return array_values(array_filter(array_map('trim', explode(',', $raw)), static fn ($v) => $v !== ''));
}
function role_options(): array {
return [
'admin' => 'Admin',
'sales' => 'Sales',
'purchasing' => 'Purchasing',
'production' => 'Production',
'accounting' => 'Accounting',
];
}
function role_label(string $role): string {
$labels = [
'admin' => 'مدير النظام',
'sales' => 'المبيعات',
'purchasing' => 'المشتريات',
'production' => 'الإنتاج',
'accounting' => 'المحاسبة',
];
return $labels[$role] ?? $role;
}
function ensure_schema(): void {
db()->exec(
"CREATE TABLE IF NOT EXISTS erp_records (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
record_type VARCHAR(40) NOT NULL,
title VARCHAR(190) NOT NULL,
code VARCHAR(80) NOT NULL,
status VARCHAR(40) NOT NULL DEFAULT 'active',
payload LONGTEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_record_type (record_type),
KEY idx_record_type_status (record_type, status),
KEY idx_code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
db()->exec(
"CREATE TABLE IF NOT EXISTS app_users (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(80) NOT NULL UNIQUE,
full_name VARCHAR(190) NOT NULL,
role VARCHAR(40) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_role (role),
KEY idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
seed_demo_data();
}
function next_code(string $prefix, string $type): string {
$stmt = db()->prepare("SELECT COUNT(*) FROM erp_records WHERE record_type = :record_type");
$stmt->execute(['record_type' => $type]);
$count = (int)$stmt->fetchColumn() + 1;
return sprintf('%s-%04d', $prefix, $count);
}
function create_record(string $type, string $title, string $code, array $payload = [], string $status = 'active'): int {
$stmt = db()->prepare(
"INSERT INTO erp_records (record_type, title, code, status, payload)
VALUES (:record_type, :title, :code, :status, :payload)"
);
$stmt->execute([
'record_type' => $type,
'title' => $title,
'code' => $code,
'status' => $status,
'payload' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
]);
return (int)db()->lastInsertId();
}
function update_record_payload(int $id, array $payload, ?string $status = null): void {
if ($status !== null) {
$stmt = db()->prepare("UPDATE erp_records SET payload = :payload, status = :status WHERE id = :id");
$stmt->execute([
'payload' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'status' => $status,
'id' => $id,
]);
return;
}
$stmt = db()->prepare("UPDATE erp_records SET payload = :payload WHERE id = :id");
$stmt->execute([
'payload' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'id' => $id,
]);
}
function fetch_records(string $type): array {
$stmt = db()->prepare("SELECT * FROM erp_records WHERE record_type = :record_type ORDER BY id DESC");
$stmt->execute(['record_type' => $type]);
$rows = $stmt->fetchAll();
foreach ($rows as &$row) {
$row['payload_data'] = json_payload($row['payload'] ?? null);
}
return $rows;
}
function recent_records(string $type, int $limit = 5): array {
$stmt = db()->prepare("SELECT * FROM erp_records WHERE record_type = :record_type ORDER BY id DESC LIMIT :limit");
$stmt->bindValue(':record_type', $type, PDO::PARAM_STR);
$stmt->bindValue(':limit', max(1, $limit), PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll();
foreach ($rows as &$row) {
$row['payload_data'] = json_payload($row['payload'] ?? null);
}
return $rows;
}
function fetch_record(string $type, int $id): ?array {
$stmt = db()->prepare("SELECT * FROM erp_records WHERE record_type = :record_type AND id = :id LIMIT 1");
$stmt->execute(['record_type' => $type, 'id' => $id]);
$row = $stmt->fetch();
if (!$row) {
return null;
}
$row['payload_data'] = json_payload($row['payload'] ?? null);
return $row;
}
function record_count(string $type): int {
$stmt = db()->prepare("SELECT COUNT(*) FROM erp_records WHERE record_type = :record_type");
$stmt->execute(['record_type' => $type]);
return (int)$stmt->fetchColumn();
}
function today_record_count(string $type): int {
$stmt = db()->prepare("SELECT COUNT(*) FROM erp_records WHERE record_type = :record_type AND DATE(created_at) = CURDATE()");
$stmt->execute(['record_type' => $type]);
return (int)$stmt->fetchColumn();
}
function record_created_date(array $record): string {
return substr((string)($record['created_at'] ?? ''), 0, 10);
}
function record_matches_date_range(array $record, ?string $startDate = null, ?string $endDate = null): bool {
$createdDate = record_created_date($record);
if ($createdDate === '') {
return false;
}
if ($startDate && $createdDate < $startDate) {
return false;
}
if ($endDate && $createdDate > $endDate) {
return false;
}
return true;
}
function filter_records_by_date(array $records, ?string $startDate = null, ?string $endDate = null): array {
return array_values(array_filter($records, static fn (array $record): bool => record_matches_date_range($record, $startDate, $endDate)));
}
function fetch_counts(): array {
$types = ['customer', 'supplier', 'product', 'sales_quote', 'sales_order', 'delivery_note', 'sales_invoice', 'purchase_order', 'manufacturing_order', 'stock_movement', 'customer_payment', 'supplier_payment', 'expense_entry'];
$counts = [];
foreach ($types as $type) {
$counts[$type] = record_count($type);
}
return $counts;
}
function low_stock_products(int $limit = 6): array {
$rows = [];
foreach (fetch_records('product') as $product) {
$payload = $product['payload_data'];
$stock = (float)($payload['stock_qty'] ?? 0);
$threshold = (float)($payload['reorder_level'] ?? 0);
if ($stock <= $threshold) {
$rows[] = $product;
}
}
usort($rows, static function (array $a, array $b): int {
return (float)($a['payload_data']['stock_qty'] ?? 0) <=> (float)($b['payload_data']['stock_qty'] ?? 0);
});
return array_slice($rows, 0, $limit);
}
function low_stock_count(): int {
return count(low_stock_products(999));
}
function total_sales_value(): float {
$total = 0.0;
foreach (fetch_records('sales_order') as $order) {
$total += (float)($order['payload_data']['grand_total'] ?? 0);
}
return $total;
}
function total_purchase_value(): float {
$total = 0.0;
foreach (fetch_records('purchase_order') as $order) {
$total += (float)($order['payload_data']['grand_total'] ?? 0);
}
return $total;
}
function today_sales_value(): float {
$total = 0.0;
foreach (recent_records('sales_order', 200) as $order) {
if (substr((string)($order['created_at'] ?? ''), 0, 10) === date('Y-m-d')) {
$total += (float)($order['payload_data']['grand_total'] ?? 0);
}
}
return $total;
}
function today_purchase_value(): float {
$total = 0.0;
foreach (recent_records('purchase_order', 200) as $order) {
if (substr((string)($order['created_at'] ?? ''), 0, 10) === date('Y-m-d')) {
$total += (float)($order['payload_data']['grand_total'] ?? 0);
}
}
return $total;
}
function total_sales_invoice_value(): float {
$total = 0.0;
foreach (fetch_records('sales_invoice') as $invoice) {
$total += (float)($invoice['payload_data']['grand_total'] ?? 0);
}
return $total;
}
function received_purchase_total(): float {
$total = 0.0;
foreach (fetch_records('purchase_order') as $order) {
if ((string)($order['status'] ?? '') === 'received') {
$total += (float)($order['payload_data']['grand_total'] ?? 0);
}
}
return $total;
}
function total_customer_receipts(): float {
$total = 0.0;
foreach (fetch_records('customer_payment') as $payment) {
$total += (float)($payment['payload_data']['amount'] ?? 0);
}
return $total;
}
function total_supplier_payments(): float {
$total = 0.0;
foreach (fetch_records('supplier_payment') as $payment) {
$total += (float)($payment['payload_data']['amount'] ?? 0);
}
return $total;
}
function total_expense_value(): float {
$total = 0.0;
foreach (fetch_records('expense_entry') as $expense) {
$total += (float)($expense['payload_data']['amount'] ?? 0);
}
return $total;
}
function today_amount_by_type(string $type, string $field = 'amount'): float {
$total = 0.0;
foreach (recent_records($type, 200) as $row) {
if (substr((string)($row['created_at'] ?? ''), 0, 10) === date('Y-m-d')) {
$total += (float)($row['payload_data'][$field] ?? 0);
}
}
return $total;
}
function receivable_total(): float {
return total_sales_invoice_value() - total_customer_receipts();
}
function payable_total(): float {
return received_purchase_total() - total_supplier_payments();
}
function expected_profit_value(): float {
return total_sales_invoice_value() - received_purchase_total() - total_expense_value();
}
function net_cashflow_value(): float {
return total_customer_receipts() - total_supplier_payments() - total_expense_value();
}
function payment_method_options(): array {
return [
'bank_transfer' => 'تحويل بنكي',
'cash' => 'نقدي',
'card' => 'بطاقة',
'cheque' => 'شيك',
];
}
function payment_method_label(string $method): string {
$labels = payment_method_options();
return $labels[$method] ?? $method;
}
function expense_category_options(): array {
return [
'operations' => 'تشغيل',
'logistics' => 'لوجستيات',
'salary' => 'رواتب',
'marketing' => 'تسويق',
'utilities' => 'مرافق',
'other' => 'أخرى',
];
}
function expense_category_label(string $category): string {
$labels = expense_category_options();
return $labels[$category] ?? $category;
}
function accounting_summary(): array {
$salesInvoices = total_sales_invoice_value();
$purchaseCommitments = received_purchase_total();
$customerReceipts = total_customer_receipts();
$supplierPayments = total_supplier_payments();
$expenses = total_expense_value();
return [
'sales_invoices' => $salesInvoices,
'purchase_commitments' => $purchaseCommitments,
'customer_receipts' => $customerReceipts,
'supplier_payments' => $supplierPayments,
'expenses' => $expenses,
'receivables' => $salesInvoices - $customerReceipts,
'payables' => $purchaseCommitments - $supplierPayments,
'expected_profit' => $salesInvoices - $purchaseCommitments - $expenses,
'net_cashflow' => $customerReceipts - $supplierPayments - $expenses,
'today_receipts' => today_amount_by_type('customer_payment'),
'today_supplier_payments' => today_amount_by_type('supplier_payment'),
'today_expenses' => today_amount_by_type('expense_entry'),
];
}
function customer_statement_rows(): array {
$rows = [];
foreach (customer_dataset() as $customer) {
$rows[$customer['id']] = [
'id' => $customer['id'],
'name' => $customer['name'],
'code' => $customer['code'],
'invoices' => 0.0,
'payments' => 0.0,
'balance' => 0.0,
'last_activity' => null,
];
}
foreach (fetch_records('sales_invoice') as $invoice) {
$payload = $invoice['payload_data'];
$customerId = (int)($payload['customer_id'] ?? 0);
if (!isset($rows[$customerId])) {
continue;
}
$rows[$customerId]['invoices'] += (float)($payload['grand_total'] ?? 0);
$rows[$customerId]['last_activity'] = max((string)($rows[$customerId]['last_activity'] ?? ''), (string)($invoice['created_at'] ?? ''));
}
foreach (fetch_records('customer_payment') as $payment) {
$payload = $payment['payload_data'];
$customerId = (int)($payload['customer_id'] ?? 0);
if (!isset($rows[$customerId])) {
continue;
}
$rows[$customerId]['payments'] += (float)($payload['amount'] ?? 0);
$rows[$customerId]['last_activity'] = max((string)($rows[$customerId]['last_activity'] ?? ''), (string)($payment['created_at'] ?? ''));
}
foreach ($rows as &$row) {
$row['balance'] = $row['invoices'] - $row['payments'];
}
unset($row);
usort($rows, static function (array $a, array $b): int {
return abs($b['balance']) <=> abs($a['balance']);
});
return array_values($rows);
}
function supplier_statement_rows(): array {
$rows = [];
foreach (supplier_dataset() as $supplier) {
$rows[$supplier['id']] = [
'id' => $supplier['id'],
'name' => $supplier['name'],
'code' => $supplier['code'],
'purchases' => 0.0,
'payments' => 0.0,
'balance' => 0.0,
'last_activity' => null,
];
}
foreach (fetch_records('purchase_order') as $order) {
if ((string)($order['status'] ?? '') !== 'received') {
continue;
}
$payload = $order['payload_data'];
$supplierId = (int)($payload['supplier_id'] ?? 0);
if (!isset($rows[$supplierId])) {
continue;
}
$rows[$supplierId]['purchases'] += (float)($payload['grand_total'] ?? 0);
$rows[$supplierId]['last_activity'] = max((string)($rows[$supplierId]['last_activity'] ?? ''), (string)($order['created_at'] ?? ''));
}
foreach (fetch_records('supplier_payment') as $payment) {
$payload = $payment['payload_data'];
$supplierId = (int)($payload['supplier_id'] ?? 0);
if (!isset($rows[$supplierId])) {
continue;
}
$rows[$supplierId]['payments'] += (float)($payload['amount'] ?? 0);
$rows[$supplierId]['last_activity'] = max((string)($rows[$supplierId]['last_activity'] ?? ''), (string)($payment['created_at'] ?? ''));
}
foreach ($rows as &$row) {
$row['balance'] = $row['purchases'] - $row['payments'];
}
unset($row);
usort($rows, static function (array $a, array $b): int {
return abs($b['balance']) <=> abs($a['balance']);
});
return array_values($rows);
}
function recent_accounting_activity(int $limit = 8): array {
$rows = [];
foreach (['customer_payment', 'supplier_payment', 'expense_entry'] as $type) {
foreach (fetch_records($type) as $row) {
$row['payload_data'] = $row['payload_data'] ?? json_payload($row['payload'] ?? null);
$rows[] = $row;
}
}
usort($rows, static function (array $a, array $b): int {
return strcmp((string)($b['created_at'] ?? ''), (string)($a['created_at'] ?? ''));
});
return array_slice($rows, 0, $limit);
}
function inventory_totals(): array {
$rawQty = 0.0;
$finishedQty = 0.0;
$all = fetch_records('product');
foreach ($all as $product) {
$payload = $product['payload_data'];
$qty = (float)($payload['stock_qty'] ?? 0);
$category = (string)($payload['category'] ?? '');
if ($category === 'مواد خام') {
$rawQty += $qty;
} else {
$finishedQty += $qty;
}
}
return [
'raw_qty' => $rawQty,
'finished_qty' => $finishedQty,
'all_qty' => $rawQty + $finishedQty,
'sku_count' => count($all),
];
}
function customer_dataset(): array {
$rows = [];
foreach (fetch_records('customer') as $customer) {
$payload = $customer['payload_data'];
$rows[] = [
'id' => (int)$customer['id'],
'name' => $customer['title'],
'code' => $customer['code'],
'branches' => array_values($payload['branches'] ?? []),
'allowed_skus' => array_values($payload['allowed_skus'] ?? []),
'price_overrides' => $payload['price_overrides'] ?? [],
];
}
return $rows;
}
function product_dataset(): array {
$rows = [];
foreach (fetch_records('product') as $product) {
$payload = $product['payload_data'];
$rows[] = [
'id' => (int)$product['id'],
'name' => $product['title'],
'code' => $product['code'],
'sku' => $payload['sku'] ?? $product['code'],
'unit' => $payload['unit'] ?? 'وحدة',
'stock_qty' => (float)($payload['stock_qty'] ?? 0),
'sale_price' => (float)($payload['sale_price'] ?? 0),
'category' => $payload['category'] ?? '',
];
}
return $rows;
}
function supplier_dataset(): array {
$rows = [];
foreach (fetch_records('supplier') as $supplier) {
$payload = $supplier['payload_data'];
$rows[] = [
'id' => (int)$supplier['id'],
'name' => $supplier['title'],
'code' => $supplier['code'],
'supplied_skus' => array_values($payload['supplied_skus'] ?? []),
'phone' => $payload['phone'] ?? '',
'email' => $payload['email'] ?? '',
];
}
return $rows;
}
function raw_material_dataset(): array {
return array_values(array_filter(product_dataset(), static fn (array $product): bool => ($product['category'] ?? '') === 'مواد خام'));
}
function finished_product_dataset(): array {
return array_values(array_filter(product_dataset(), static fn (array $product): bool => ($product['category'] ?? '') !== 'مواد خام'));
}
function manufacturing_quality_options(): array {
return [
'accepted' => 'مطابق',
'rework' => 'بحاجة إعادة معالجة',
'rejected' => 'مرفوض',
];
}
function manufacturing_quality_label(string $quality): string {
$labels = manufacturing_quality_options();
return $labels[$quality] ?? $quality;
}
function manufacturing_output_qty(array $payload): float {
return (string)($payload['quality_status'] ?? '') === 'rejected' ? 0.0 : (float)($payload['finished_qty'] ?? 0);
}
function sales_document_types(): array {
return [
'sales_quote' => ['label' => 'عرض سعر', 'prefix' => 'QT'],
'sales_order' => ['label' => 'أمر بيع', 'prefix' => 'SO'],
'delivery_note' => ['label' => 'أمر تسليم', 'prefix' => 'DN'],
'sales_invoice' => ['label' => 'فاتورة', 'prefix' => 'INV'],
];
}
function sales_document_label(string $type): string {
$types = sales_document_types();
return $types[$type]['label'] ?? $type;
}
function sales_document_prefix(string $type): string {
$types = sales_document_types();
return $types[$type]['prefix'] ?? strtoupper(substr($type, 0, 3));
}
function sales_document_record_types(): array {
return array_keys(sales_document_types());
}
function fetch_sales_document_by_id(int $id): ?array {
$stmt = db()->prepare("SELECT * FROM erp_records WHERE id = :id LIMIT 1");
$stmt->execute(['id' => $id]);
$row = $stmt->fetch();
if (!$row || !in_array((string)$row['record_type'], sales_document_record_types(), true)) {
return null;
}
$row['payload_data'] = json_payload($row['payload'] ?? null);
return $row;
}
function fetch_sales_documents(): array {
$types = sales_document_record_types();
$placeholders = implode(',', array_fill(0, count($types), '?'));
$stmt = db()->prepare("SELECT * FROM erp_records WHERE record_type IN ($placeholders) ORDER BY id DESC");
$stmt->execute($types);
$rows = $stmt->fetchAll();
foreach ($rows as &$row) {
$row['payload_data'] = json_payload($row['payload'] ?? null);
}
return $rows;
}
function sales_document_conversion_targets(string $type): array {
return match ($type) {
'sales_quote' => ['sales_order'],
'sales_order' => ['delivery_note', 'sales_invoice'],
'delivery_note' => ['sales_invoice'],
default => [],
};
}
function sales_document_child_exists(int $sourceId, string $targetType): bool {
foreach (fetch_records($targetType) as $record) {
if ((int)($record['payload_data']['source_document_id'] ?? 0) === $sourceId) {
return true;
}
}
return false;
}
function sales_document_children(int $sourceId): array {
$children = [];
foreach (fetch_sales_documents() as $record) {
if ((int)($record['payload_data']['source_document_id'] ?? 0) === $sourceId) {
$children[] = $record;
}
}
return $children;
}
function movement_direction_label(float $qtyChange): string {
return $qtyChange >= 0 ? 'إضافة' : 'خصم';
}
function movement_badge_class(float $qtyChange): string {
return $qtyChange >= 0 ? 'text-bg-success' : 'text-bg-danger';
}
function order_status_label(string $status): string {
return match ($status) {
'confirmed' => 'مؤكد',
'draft' => 'مسودة',
'received' => 'مستلم',
'completed' => 'مكتمل',
'in_progress' => 'قيد التنفيذ',
'active' => 'نشط',
'posted' => 'مرحل',
default => $status,
};
}
function status_badge_class(string $status): string {
return match ($status) {
'confirmed', 'received', 'completed', 'posted' => 'text-bg-dark',
'in_progress' => 'text-bg-warning',
'draft' => 'text-bg-secondary',
default => 'text-bg-light',
};
}
function format_money(float $amount): string {
return number_format($amount, 2) . ' ر.س';
}
function insert_user(string $username, string $fullName, string $role, string $password): void {
$stmt = db()->prepare(
"INSERT INTO app_users (username, full_name, role, password_hash, status)
VALUES (:username, :full_name, :role, :password_hash, 'active')"
);
$stmt->execute([
'username' => $username,
'full_name' => $fullName,
'role' => $role,
'password_hash' => password_hash($password, PASSWORD_BCRYPT),
]);
}
function default_account_credentials(): array {
return [
['username' => 'admin', 'password' => 'ChangeMe@123', 'role' => 'admin'],
['username' => 'sales', 'password' => 'ChangeMe@123', 'role' => 'sales'],
['username' => 'purchasing', 'password' => 'ChangeMe@123', 'role' => 'purchasing'],
['username' => 'production', 'password' => 'ChangeMe@123', 'role' => 'production'],
['username' => 'accounting', 'password' => 'ChangeMe@123', 'role' => 'accounting'],
];
}
function seed_demo_data(): void {
$userCount = (int)db()->query("SELECT COUNT(*) FROM app_users")->fetchColumn();
if ($userCount === 0) {
insert_user('admin', 'مدير النظام', 'admin', 'ChangeMe@123');
insert_user('sales', 'موظف المبيعات', 'sales', 'ChangeMe@123');
insert_user('purchasing', 'موظف المشتريات', 'purchasing', 'ChangeMe@123');
insert_user('production', 'مشرف الإنتاج', 'production', 'ChangeMe@123');
insert_user('accounting', 'المحاسب', 'accounting', 'ChangeMe@123');
}
$stmt = db()->prepare("SELECT COUNT(*) FROM erp_records WHERE record_type = :record_type");
$stmt->execute(['record_type' => 'customer']);
if ((int)$stmt->fetchColumn() === 0) {
create_record('customer', 'شركة النخبة للتوزيع', 'CUS-0001', [
'phone' => '0500000001',
'email' => 'sales@nakhba.local',
'branches' => ['الرياض', 'جدة'],
'allowed_skus' => ['P-100', 'P-200'],
'price_overrides' => ['P-100' => 95, 'P-200' => 145],
'notes' => 'عميل جملة مع أسعار خاصة وخيارات أصناف محددة.',
]);
create_record('customer', 'مؤسسة المدار التجارية', 'CUS-0002', [
'phone' => '0500000002',
'email' => 'ops@almadar.local',
'branches' => ['الدمام'],
'allowed_skus' => ['P-100', 'P-300'],
'price_overrides' => ['P-300' => 42],
'notes' => 'عميل تجزئة بفرع واحد.',
]);
}
$stmt->execute(['record_type' => 'supplier']);
if ((int)$stmt->fetchColumn() === 0) {
create_record('supplier', 'مصنع التوريد الحديث', 'SUP-0001', [
'phone' => '0500000300',
'email' => 'supply@modern.local',
'supplied_skus' => ['RAW-001', 'P-100'],
'notes' => 'مورد أساسي للخامات وبعض مواد التعبئة.',
]);
create_record('supplier', 'الشركة العربية للمواد', 'SUP-0002', [
'phone' => '0500000301',
'email' => 'proc@arabic.local',
'supplied_skus' => ['RAW-001', 'P-200', 'P-300'],
'notes' => 'مورد بديل للحالات العاجلة.',
]);
}
$stmt->execute(['record_type' => 'product']);
if ((int)$stmt->fetchColumn() === 0) {
create_record('product', 'مادة خام بودرة أساسية', 'RAW-001', [
'sku' => 'RAW-001',
'unit' => 'كجم',
'category' => 'مواد خام',
'stock_qty' => 180,
'reorder_level' => 40,
'sale_price' => 0,
'notes' => 'مخزون خام للتصنيع.',
]);
create_record('product', 'منتج نهائي عبوة 1 لتر', 'P-100', [
'sku' => 'P-100',
'unit' => 'عبوة',
'category' => 'منتج نهائي',
'stock_qty' => 64,
'reorder_level' => 12,
'sale_price' => 110,
'notes' => 'يباع للعملاء مع تسعير خاص لبعض الحسابات.',
]);
create_record('product', 'منتج نهائي عبوة 5 لتر', 'P-200', [
'sku' => 'P-200',
'unit' => 'عبوة',
'category' => 'منتج نهائي',
'stock_qty' => 28,
'reorder_level' => 10,
'sale_price' => 160,
'notes' => 'من أكثر الأصناف استخدامًا في أوامر البيع.',
]);
create_record('product', 'منتج نهائي عبوة تجريبية', 'P-300', [
'sku' => 'P-300',
'unit' => 'عبوة',
'category' => 'منتج نهائي',
'stock_qty' => 9,
'reorder_level' => 8,
'sale_price' => 48,
'notes' => 'صنف منخفض المخزون يظهر في التنبيهات.',
]);
}
}
function current_user(): ?array {
static $cached = null;
static $loaded = false;
if ($loaded) {
return $cached;
}
$loaded = true;
$userId = (int)($_SESSION['user_id'] ?? 0);
if ($userId <= 0) {
return $cached = null;
}
$stmt = db()->prepare("SELECT id, username, full_name, role, status FROM app_users WHERE id = :id LIMIT 1");
$stmt->execute(['id' => $userId]);
$user = $stmt->fetch();
if (!$user || ($user['status'] ?? '') !== 'active') {
unset($_SESSION['user_id']);
return $cached = null;
}
return $cached = $user;
}
function login_attempt(string $username, string $password): ?string {
$stmt = db()->prepare("SELECT * FROM app_users WHERE username = :username LIMIT 1");
$stmt->execute(['username' => $username]);
$user = $stmt->fetch();
if (!$user || ($user['status'] ?? '') !== 'active' || !password_verify($password, (string)$user['password_hash'])) {
return 'بيانات الدخول غير صحيحة.';
}
session_regenerate_id(true);
$_SESSION['user_id'] = (int)$user['id'];
return null;
}
function logout_user(): void {
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$params = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], (bool)$params['secure'], (bool)$params['httponly']);
}
session_destroy();
}
function is_logged_in(): bool {
return current_user() !== null;
}
function permission_map(): array {
return [
'dashboard' => ['admin', 'sales', 'purchasing', 'production', 'accounting'],
'customers' => ['admin', 'sales'],
'products' => ['admin', 'sales', 'purchasing', 'production', 'accounting'],
'orders' => ['admin', 'sales', 'accounting'],
'manufacturing' => ['admin', 'production'],
'suppliers' => ['admin', 'purchasing'],
'purchases' => ['admin', 'purchasing', 'accounting'],
'stock' => ['admin', 'sales', 'purchasing', 'production', 'accounting'],
'documents' => ['admin', 'sales', 'purchasing', 'accounting'],
'accounting' => ['admin', 'accounting'],
'reports' => ['admin', 'sales', 'purchasing', 'production', 'accounting'],
];
}
function can_access(string $area): bool {
$user = current_user();
if (!$user) {
return false;
}
if (($user['role'] ?? '') === 'admin') {
return true;
}
$map = permission_map();
return in_array((string)$user['role'], $map[$area] ?? [], true);
}
function require_login(): void {
if (!is_logged_in()) {
set_flash('warning', 'سجّل الدخول أولًا للوصول إلى النظام.');
redirect('login.php');
}
}
function require_permission(string $area): void {
require_login();
if (!can_access($area)) {
http_response_code(403);
render_header('غير مصرح', 'ليس لديك صلاحية للوصول إلى هذه الصفحة.', 'dashboard');
echo '<div class="panel-card"><h1 class="section-title mb-3">غير مصرح بالوصول</h1><p class="section-copy mb-0">الدور الحالي لا يملك صلاحية لهذه الصفحة. عد إلى لوحة التحكم أو سجّل الدخول بحساب مناسب.</p></div>';
render_footer();
exit;
}
}
function active_nav(string $key, string $active): string {
return $key === $active ? 'active' : '';
}
function nav_items(): array {
return [
['key' => 'dashboard', 'label' => 'لوحة التحكم', 'href' => 'index.php', 'area' => 'dashboard'],
['key' => 'customers', 'label' => 'العملاء', 'href' => 'customers.php', 'area' => 'customers'],
['key' => 'suppliers', 'label' => 'الموردون', 'href' => 'suppliers.php', 'area' => 'suppliers'],
['key' => 'products', 'label' => 'الأصناف', 'href' => 'products.php', 'area' => 'products'],
['key' => 'orders', 'label' => 'المبيعات', 'href' => 'sales_orders.php', 'area' => 'orders'],
['key' => 'manufacturing', 'label' => 'التصنيع', 'href' => 'manufacturing.php', 'area' => 'manufacturing'],
['key' => 'purchases', 'label' => 'المشتريات', 'href' => 'purchases.php', 'area' => 'purchases'],
['key' => 'accounting', 'label' => 'المحاسبة', 'href' => 'accounting.php', 'area' => 'accounting'],
['key' => 'reports', 'label' => 'التقارير', 'href' => 'reports.php', 'area' => 'reports'],
['key' => 'stock', 'label' => 'حركات المخزون', 'href' => 'stock_movements.php', 'area' => 'stock'],
];
}
function adjust_product_stock(array $product, float $qtyChange, string $movementType, string $referenceCode, string $referenceType, int $referenceId = 0, string $notes = ''): array {
$payload = $product['payload_data'];
$before = (float)($payload['stock_qty'] ?? 0);
$after = $before + $qtyChange;
if ($after < 0) {
throw new RuntimeException('Insufficient stock.');
}
$payload['stock_qty'] = $after;
update_record_payload((int)$product['id'], $payload);
$movementPayload = [
'product_id' => (int)$product['id'],
'product_name' => $product['title'],
'sku' => $payload['sku'] ?? $product['code'],
'unit' => $payload['unit'] ?? 'وحدة',
'qty_change' => $qtyChange,
'qty_before' => $before,
'qty_after' => $after,
'movement_type' => $movementType,
'reference_code' => $referenceCode,
'reference_type' => $referenceType,
'reference_id' => $referenceId,
'notes' => $notes,
'created_by' => current_user()['username'] ?? 'system',
];
create_record(
'stock_movement',
(($qtyChange >= 0 ? 'إضافة ' : 'خصم ') . $product['title']),
next_code('MOV', 'stock_movement'),
$movementPayload,
'confirmed'
);
return ['before' => $before, 'after' => $after];
}
function render_header(string $pageTitle, string $pageDescription, string $active = 'dashboard'): void {
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? $pageDescription;
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
$projectName = app_name();
$user = current_user();
?>
<!doctype html>
<html lang="ar" dir="rtl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= e($pageTitle) ?> | <?= e($projectName) ?></title>
<meta name="description" content="<?= e($projectDescription ?: $pageDescription) ?>">
<meta name="robots" content="noindex, nofollow">
<?php if ($projectDescription): ?>
<meta property="og:description" content="<?= e($projectDescription) ?>">
<meta property="twitter:description" content="<?= e($projectDescription) ?>">
<?php endif; ?>
<?php if ($projectImageUrl): ?>
<meta property="og:image" content="<?= e($projectImageUrl) ?>">
<meta property="twitter:image" content="<?= e($projectImageUrl) ?>">
<?php endif; ?>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.rtl.min.css" rel="stylesheet">
<link rel="stylesheet" href="assets/css/custom.css?v=<?= time() ?>">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-light bg-white border-bottom sticky-top app-navbar">
<div class="container-fluid container-xl">
<a class="navbar-brand fw-semibold" href="index.php">
<span class="brand-dot"></span>
<?= e($projectName) ?>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav" aria-controls="mainNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="mainNav">
<ul class="navbar-nav ms-auto align-items-lg-center gap-lg-1">
<?php foreach (nav_items() as $item): ?>
<?php if ($user && can_access($item['area'])): ?>
<li class="nav-item"><a class="nav-link <?= active_nav($item['key'], $active) ?>" href="<?= e($item['href']) ?>"><?= e($item['label']) ?></a></li>
<?php endif; ?>
<?php endforeach; ?>
<li class="nav-item"><a class="nav-link" href="healthz.php">Health</a></li>
</ul>
<div class="d-flex align-items-center gap-2 ms-lg-3 mt-3 mt-lg-0">
<?php if ($user): ?>
<div class="user-chip text-end">
<strong><?= e($user['full_name']) ?></strong>
<span><?= e(role_label((string)$user['role'])) ?></span>
</div>
<a href="logout.php" class="btn btn-outline-secondary btn-sm">خروج</a>
<?php else: ?>
<a href="login.php" class="btn btn-dark btn-sm">تسجيل الدخول</a>
<?php endif; ?>
</div>
</div>
</div>
</nav>
<div class="page-shell">
<div class="container-xl py-4 py-lg-5">
<?php $flash = get_flash(); ?>
<?php if ($flash): ?>
<div class="alert alert-<?= e($flash['type']) ?> alert-dismissible fade show app-alert" role="alert">
<?= e($flash['message']) ?>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<?php endif; ?>
<?php
}
function render_footer(): void {
?>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="assets/js/main.js?v=<?= time() ?>"></script>
</body>
</html>
<?php
}
function redirect(string $path): void {
header('Location: ' . $path);
exit;
}
ensure_schema();