1075 lines
38 KiB
PHP
1075 lines
38 KiB
PHP
<?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();
|